Skip to content

Commit b24f844

Browse files
authored
feat: add signature agent (#71)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduces signature-agent support and key handling improvements for building the Cloudflare web-bot-auth extension. > > - Adds `--signature-agent` flag to `extensions build-web-bot-auth` in `cmd/extensions.go`; passes through to `extensions.BuildWebBotAuth` and exports `SIGNATURE_AGENT_URL` during `npm run build:chrome` and `bundle:chrome`. > - Enhances build to accept JWK or PEM keys, converts as needed (`util.ConvertPEMToJWK`/`ConvertJWKToPEM`), writes `private_key.pem`, and replaces the background key import with an inline `jwk` via `injectJWKIntoBackgroundTs`. > - Switches web-bot-auth download to Kernel fork and updates pinned commit (`pkg/extensions/webbotauth.go`). > - Adds `ConvertPEMToJWK` in `pkg/util/crypto.go` with comprehensive tests, including roundtrip PEM↔JWK in `pkg/util/crypto_test.go`. > - Minor: updates policy/build file rewrites and success output remain; artifacts copying unchanged. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 69437a1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e85eacc commit b24f844

File tree

4 files changed

+253
-19
lines changed

4 files changed

+253
-19
lines changed

cmd/extensions.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ var extensionsBuildWebBotAuthCmd = &cobra.Command{
424424
url, _ := cmd.Flags().GetString("url")
425425
keyPath, _ := cmd.Flags().GetString("key")
426426
uploadName, _ := cmd.Flags().GetString("upload")
427-
427+
signatureAgentURL, _ := cmd.Flags().GetString("signature-agent")
428428
// Use upload name for extension name, or default to "web-bot-auth"
429429
extensionName := "web-bot-auth"
430430
if uploadName != "" {
@@ -433,11 +433,12 @@ var extensionsBuildWebBotAuthCmd = &cobra.Command{
433433

434434
// Build the extension
435435
result, err := extensions.BuildWebBotAuth(cmd.Context(), extensions.ExtensionsBuildWebBotAuthInput{
436-
Output: output,
437-
HostURL: url,
438-
KeyPath: keyPath,
439-
ExtensionName: extensionName,
440-
AutoUpload: uploadName != "",
436+
Output: output,
437+
HostURL: url,
438+
KeyPath: keyPath,
439+
ExtensionName: extensionName,
440+
AutoUpload: uploadName != "",
441+
SignatureAgentURL: signatureAgentURL,
441442
})
442443
if err != nil {
443444
return err
@@ -478,4 +479,5 @@ func init() {
478479
extensionsBuildWebBotAuthCmd.Flags().String("url", "http://127.0.0.1:10001", "Base URL for update.xml and policy templates")
479480
extensionsBuildWebBotAuthCmd.Flags().String("key", "", "Path to Ed25519 private key file (JWK or PEM format)")
480481
extensionsBuildWebBotAuthCmd.Flags().String("upload", "", "Upload extension to Kernel with specified name (e.g., --upload web-bot-auth)")
482+
extensionsBuildWebBotAuthCmd.Flags().String("signature-agent", "", "URL of the signature agent")
481483
}

pkg/extensions/webbotauth.go

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,24 @@ import (
1818
)
1919

2020
const (
21-
defaultLocalhostURL = "http://localhost:8000"
22-
defaultDirMode = 0755
23-
defaultFileMode = 0644
24-
// Current: v0.6.0 release (e3d76846b64be03ae00e2b9e53b697beab81541d) - Dec 19, 2025
25-
webBotAuthCommit = "e3d76846b64be03ae00e2b9e53b697beab81541d"
26-
webBotAuthDownloadURL = "https://github.com/cloudflare/web-bot-auth/archive/" + webBotAuthCommit + ".zip"
21+
defaultLocalhostURL = "http://localhost:8000"
22+
defaultDirMode = 0755
23+
defaultFileMode = 0644
24+
webBotAuthCommit = "3f437a1fb17dcfd31a33b268f2f9447a83122057"
25+
webBotAuthDownloadURL = "https://github.com/kernel/web-bot-auth/archive/" + webBotAuthCommit + ".zip"
2726
downloadTimeout = 5 * time.Minute
2827
// defaultWebBotAuthKey is the RFC9421 test key that works with Cloudflare's test site
2928
// https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/
3029
defaultWebBotAuthKey = `{"kty":"OKP","crv":"Ed25519","d":"n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU","x":"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"}`
3130
)
3231

3332
type ExtensionsBuildWebBotAuthInput struct {
34-
Output string
35-
HostURL string
36-
KeyPath string // Path to user's JWK or PEM file (optional, defaults to RFC9421 test key)
37-
ExtensionName string // Name for the extension paths (defaults to "web-bot-auth")
38-
AutoUpload bool // Whether the extension will be automatically uploaded after building
33+
Output string
34+
HostURL string
35+
KeyPath string // Path to user's JWK or PEM file (optional, defaults to RFC9421 test key)
36+
ExtensionName string // Name for the extension paths (defaults to "web-bot-auth")
37+
AutoUpload bool // Whether the extension will be automatically uploaded after building
38+
SignatureAgentURL string // URL of the signature agent
3939
}
4040

4141
// BuildWebBotAuthOutput contains the result of building the extension
@@ -100,7 +100,7 @@ func BuildWebBotAuth(ctx context.Context, in ExtensionsBuildWebBotAuthInput) (*B
100100
}
101101

102102
// Build extension
103-
extensionID, err := buildWebBotAuthExtension(ctx, browserExtDir, in.HostURL, keyData, in.ExtensionName)
103+
extensionID, err := buildWebBotAuthExtension(ctx, browserExtDir, in.HostURL, keyData, in.ExtensionName, in.SignatureAgentURL)
104104
if err != nil {
105105
return nil, err
106106
}
@@ -219,27 +219,35 @@ func downloadAndExtractWebBotAuth(ctx context.Context) (browserExtDir string, cl
219219

220220
// buildWebBotAuthExtension modifies templates, builds the extension, and returns the extension ID
221221
// extensionName is used for URL paths (e.g., "web-bot-auth") instead of the Chrome extension ID
222-
func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyData, extensionName string) (string, error) {
222+
func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyData, extensionName, signatureAgentURL string) (string, error) {
223223
// Normalize hostURL by removing trailing slashes to prevent double slashes in URLs
224224
hostURL = strings.TrimRight(hostURL, "/")
225225

226226
// Validate key and write to browserExtDir before building
227227
pterm.Info.Println("Validating key...")
228228
var pemData []byte
229229
var err error
230+
// JWK data is used for keyid signing in background.ts
231+
var jwkData string
230232

231233
if util.IsPEMKey(keyData) {
232234
// Key is already in PEM format, validate it
233235
if err := util.ValidatePEMKey(keyData); err != nil {
234236
return "", fmt.Errorf("failed to validate PEM key: %w", err)
235237
}
238+
239+
jwkData, err = util.ConvertPEMToJWK(keyData)
240+
if err != nil {
241+
return "", fmt.Errorf("failed to convert PEM to JWK: %w", err)
242+
}
236243
pemData = []byte(keyData)
237244
} else {
238245
// Key is in JWK format, convert to PEM
239246
pemData, err = util.ConvertJWKToPEM(keyData)
240247
if err != nil {
241248
return "", fmt.Errorf("failed to convert JWK to PEM: %w", err)
242249
}
250+
jwkData = keyData
243251
}
244252

245253
privateKeyPath := filepath.Join(browserExtDir, "private_key.pem")
@@ -248,6 +256,14 @@ func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyDa
248256
}
249257
pterm.Success.Println("Private key written successfully")
250258

259+
// Inject the JWK into background.ts (replacing the hardcoded test key)
260+
pterm.Info.Println("Injecting custom JWK into background.ts...")
261+
backgroundTsPath := filepath.Join(browserExtDir, "src", "background.ts")
262+
if err := injectJWKIntoBackgroundTs(backgroundTsPath, jwkData); err != nil {
263+
return "", fmt.Errorf("failed to inject JWK: %w", err)
264+
}
265+
pterm.Success.Println("Custom JWK injected successfully")
266+
251267
// Modify template files
252268
pterm.Info.Println("Modifying templates with host URL...")
253269

@@ -293,6 +309,7 @@ func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyDa
293309
pterm.Info.Println("Building extension...")
294310
npmBuild := exec.CommandContext(ctx, "npm", "run", "build:chrome")
295311
npmBuild.Dir = browserExtDir
312+
npmBuild.Env = append(os.Environ(), "SIGNATURE_AGENT_URL="+signatureAgentURL)
296313
npmBuild.Stdout = os.Stdout
297314
npmBuild.Stderr = os.Stderr
298315
if err := npmBuild.Run(); err != nil {
@@ -303,6 +320,7 @@ func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyDa
303320
pterm.Info.Println("Bundling extension...")
304321
npmBundle := exec.CommandContext(ctx, "npm", "run", "bundle:chrome")
305322
npmBundle.Dir = browserExtDir
323+
npmBundle.Env = append(os.Environ(), "SIGNATURE_AGENT_URL="+signatureAgentURL)
306324
var bundleOutput bytes.Buffer
307325
npmBundle.Stdout = io.MultiWriter(os.Stdout, &bundleOutput)
308326
npmBundle.Stderr = os.Stderr
@@ -347,6 +365,34 @@ func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyDa
347365
return extensionID, nil
348366
}
349367

368+
// injectJWKIntoBackgroundTs replaces the hardcoded test key import with the custom JWK
369+
func injectJWKIntoBackgroundTs(backgroundTsPath, jwkData string) error {
370+
content, err := os.ReadFile(backgroundTsPath)
371+
if err != nil {
372+
return fmt.Errorf("failed to read background.ts: %w", err)
373+
}
374+
375+
contentStr := string(content)
376+
377+
// Replace the import line with an inline constant
378+
// Find: import jwk from "../../rfc9421-keys/ed25519.json" assert { type: "json" };
379+
// Replace with: const jwk = {your-jwk-here};
380+
searchPattern := `import jwk from "../../rfc9421-keys/ed25519.json" assert { type: "json" };`
381+
replacement := fmt.Sprintf("const jwk = %s;", jwkData)
382+
383+
if !strings.Contains(contentStr, searchPattern) {
384+
return fmt.Errorf("could not find JWK import statement in background.ts")
385+
}
386+
387+
contentStr = strings.Replace(contentStr, searchPattern, replacement, 1)
388+
389+
if err := os.WriteFile(backgroundTsPath, []byte(contentStr), 0644); err != nil {
390+
return fmt.Errorf("failed to write modified background.ts: %w", err)
391+
}
392+
393+
return nil
394+
}
395+
350396
// copyExtensionArtifacts copies built extension files to the output directory
351397
func copyExtensionArtifacts(browserExtDir, outputDir string) error {
352398
pterm.Info.Println("Copying extension files to output directory...")

pkg/util/crypto.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,50 @@ func ConvertJWKToPEM(jwkJSON string) ([]byte, error) {
8686

8787
return pem.EncodeToMemory(pemBlock), nil
8888
}
89+
90+
// ConvertPEMToJWK converts an Ed25519 PEM private key to JWK format
91+
func ConvertPEMToJWK(pemData string) (string, error) {
92+
// Decode PEM block
93+
block, _ := pem.Decode([]byte(pemData))
94+
if block == nil {
95+
return "", fmt.Errorf("failed to decode PEM block")
96+
}
97+
98+
if block.Type != "PRIVATE KEY" {
99+
return "", fmt.Errorf("invalid PEM type: expected PRIVATE KEY, got %s", block.Type)
100+
}
101+
102+
// Parse PKCS#8 private key
103+
privateKeyInterface, err := x509.ParsePKCS8PrivateKey(block.Bytes)
104+
if err != nil {
105+
return "", fmt.Errorf("failed to parse PKCS#8 private key: %w", err)
106+
}
107+
108+
// Ensure it's an Ed25519 key
109+
privateKey, ok := privateKeyInterface.(ed25519.PrivateKey)
110+
if !ok {
111+
return "", fmt.Errorf("invalid key type: expected Ed25519 private key, got %T", privateKeyInterface)
112+
}
113+
114+
// Extract seed (first 32 bytes of Ed25519 private key)
115+
seed := privateKey.Seed()
116+
117+
// Extract public key (last 32 bytes of Ed25519 private key)
118+
publicKey := privateKey.Public().(ed25519.PublicKey)
119+
120+
// Encode to base64url (without padding)
121+
jwk := jwkKey{
122+
Kty: "OKP",
123+
Crv: "Ed25519",
124+
D: base64.RawURLEncoding.EncodeToString(seed),
125+
X: base64.RawURLEncoding.EncodeToString(publicKey),
126+
}
127+
128+
// Marshal to JSON
129+
jwkJSON, err := json.Marshal(jwk)
130+
if err != nil {
131+
return "", fmt.Errorf("failed to marshal JWK: %w", err)
132+
}
133+
134+
return string(jwkJSON), nil
135+
}

pkg/util/crypto_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/ed25519"
55
"crypto/x509"
66
"encoding/base64"
7+
"encoding/json"
78
"encoding/pem"
89
"testing"
910

@@ -193,3 +194,141 @@ func TestConvertJWKToPEM(t *testing.T) {
193194
})
194195
}
195196
}
197+
198+
func TestConvertPEMToJWK(t *testing.T) {
199+
tests := []struct {
200+
name string
201+
pemData string
202+
wantErr bool
203+
errMsg string
204+
wantKty string
205+
wantCrv string
206+
validateFn func(t *testing.T, jwkJSON string)
207+
}{
208+
{
209+
name: "valid Ed25519 PEM key",
210+
pemData: `-----BEGIN PRIVATE KEY-----
211+
MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF
212+
-----END PRIVATE KEY-----`,
213+
wantErr: false,
214+
wantKty: "OKP",
215+
wantCrv: "Ed25519",
216+
validateFn: func(t *testing.T, jwkJSON string) {
217+
// Parse the JWK
218+
var jwk jwkKey
219+
err := json.Unmarshal([]byte(jwkJSON), &jwk)
220+
require.NoError(t, err)
221+
222+
// Verify structure
223+
require.NotEmpty(t, jwk.D, "Private key component 'd' should not be empty")
224+
require.NotEmpty(t, jwk.X, "Public key component 'x' should not be empty")
225+
226+
// Verify the private key can be decoded
227+
privKeyBytes, err := base64.RawURLEncoding.DecodeString(jwk.D)
228+
require.NoError(t, err)
229+
assert.Len(t, privKeyBytes, ed25519.SeedSize, "Private key should be 32 bytes")
230+
231+
// Verify the public key can be decoded
232+
pubKeyBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
233+
require.NoError(t, err)
234+
assert.Len(t, pubKeyBytes, ed25519.PublicKeySize, "Public key should be 32 bytes")
235+
},
236+
},
237+
{
238+
name: "invalid PEM format",
239+
pemData: "not a pem key",
240+
wantErr: true,
241+
errMsg: "failed to decode PEM block",
242+
},
243+
{
244+
name: "wrong PEM type (public key)",
245+
pemData: `-----BEGIN PUBLIC KEY-----
246+
MCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=
247+
-----END PUBLIC KEY-----`,
248+
wantErr: true,
249+
errMsg: "invalid PEM type",
250+
},
251+
{
252+
name: "invalid PKCS8 data",
253+
pemData: `-----BEGIN PRIVATE KEY-----
254+
aW52YWxpZCBkYXRh
255+
-----END PRIVATE KEY-----`,
256+
wantErr: true,
257+
errMsg: "failed to parse PKCS#8 private key",
258+
},
259+
}
260+
261+
for _, tt := range tests {
262+
t.Run(tt.name, func(t *testing.T) {
263+
jwkJSON, err := ConvertPEMToJWK(tt.pemData)
264+
if tt.wantErr {
265+
require.Error(t, err)
266+
if tt.errMsg != "" {
267+
assert.Contains(t, err.Error(), tt.errMsg)
268+
}
269+
return
270+
}
271+
272+
require.NoError(t, err)
273+
require.NotEmpty(t, jwkJSON)
274+
275+
// Parse and verify JWK structure
276+
var jwk jwkKey
277+
err = json.Unmarshal([]byte(jwkJSON), &jwk)
278+
require.NoError(t, err, "JWK should be valid JSON")
279+
280+
// Verify basic fields
281+
assert.Equal(t, tt.wantKty, jwk.Kty, "Key type should be OKP")
282+
assert.Equal(t, tt.wantCrv, jwk.Crv, "Curve should be Ed25519")
283+
284+
// Run custom validation if provided
285+
if tt.validateFn != nil {
286+
tt.validateFn(t, jwkJSON)
287+
}
288+
})
289+
}
290+
}
291+
292+
func TestPEMToJWKRoundtrip(t *testing.T) {
293+
// Test that converting PEM -> JWK -> PEM produces equivalent keys
294+
originalPEM := `-----BEGIN PRIVATE KEY-----
295+
MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF
296+
-----END PRIVATE KEY-----`
297+
298+
// Convert PEM to JWK
299+
jwkJSON, err := ConvertPEMToJWK(originalPEM)
300+
require.NoError(t, err)
301+
require.NotEmpty(t, jwkJSON)
302+
303+
// Convert JWK back to PEM
304+
pemData, err := ConvertJWKToPEM(jwkJSON)
305+
require.NoError(t, err)
306+
require.NotEmpty(t, pemData)
307+
308+
// Parse both keys and verify they're the same
309+
block1, _ := pem.Decode([]byte(originalPEM))
310+
require.NotNil(t, block1)
311+
key1, err := x509.ParsePKCS8PrivateKey(block1.Bytes)
312+
require.NoError(t, err)
313+
ed25519Key1 := key1.(ed25519.PrivateKey)
314+
315+
block2, _ := pem.Decode(pemData)
316+
require.NotNil(t, block2)
317+
key2, err := x509.ParsePKCS8PrivateKey(block2.Bytes)
318+
require.NoError(t, err)
319+
ed25519Key2 := key2.(ed25519.PrivateKey)
320+
321+
// Compare private keys
322+
assert.Equal(t, ed25519Key1, ed25519Key2, "Keys should be identical after roundtrip")
323+
324+
// Verify signatures match
325+
message := []byte("test message for signature verification")
326+
sig1 := ed25519.Sign(ed25519Key1, message)
327+
sig2 := ed25519.Sign(ed25519Key2, message)
328+
329+
pubKey1 := ed25519Key1.Public().(ed25519.PublicKey)
330+
pubKey2 := ed25519Key2.Public().(ed25519.PublicKey)
331+
332+
assert.True(t, ed25519.Verify(pubKey1, message, sig2), "Key1 should verify signature from Key2")
333+
assert.True(t, ed25519.Verify(pubKey2, message, sig1), "Key2 should verify signature from Key1")
334+
}

0 commit comments

Comments
 (0)