使用 Web Bot Auth 对出站请求进行签名

Web Bot Auth (WBA) 协议可让您使用 HTTP 消息签名 (RFC 9421),通过加密方式证明智能体或漫游器的身份。

当您对出站请求进行签名时,Google Cloud Fraud Defense(reCAPTCHA 的演进版本)等服务可以直接验证您的代理,而无需仅依赖 IP 地址或 User-Agent 字符串。

如需使用 WBA 对代理进行身份验证,请完成以下任务:

  1. 生成 Ed25519 密钥对,并将公钥格式化为 JSON Web 密钥 (JWK) 集。
  2. 在众所周知的 HTTPS 端点托管公钥目录。
  3. 使用您的私钥对每个出站 HTTP 请求进行签名。
  4. 可选:对实验性 Agent-Context 标头进行签名,以隔离共享签名密钥的租户之间的声誉。

生成 Ed25519 密钥对

WBA 需要 Ed25519 (RFC 8032) 非对称密钥对。您需要对私钥保密,以便对出站 HTTP 请求进行签名,并在 JSON Web 密钥 (JWK) 集 (RFC 7517) 中发布公钥。

使用 OpenSSL 和 jwker 生成密钥对

您可以使用 OpenSSL 生成 Ed25519 密钥对,并使用开源 jwker 工具将公钥转换为 JWK:

  1. 生成 PEM 格式的 Ed25519 私钥:

    openssl genpkey -algorithm ed25519 -out private-key.pem
    
  2. 提取 PEM 格式的公钥:

    openssl pkey -in private-key.pem -pubout -out public-key.pem
    
  3. 如果您尚未安装 jwker,请按照 jwker 安装说明进行安装。

  4. 使用 jwker 将 PEM 公钥转换为 JWK 文件:

    jwker public-key.pem public-key.jwk
    

    此命令会将单个 JWK 对象写入 public-key.jwk:

    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "16JuFxlu7PYOFULk_W6t3sZO4NF8Yj-2QpBxCAiXQfs"
    }
    
  5. 将 JWK 对象封装在 JWK 集 keys 数组 (jwks.json) 中,并添加必需的 alg、use 和 kid 字段:

    {
      "keys": [
        {
          "kty": "OKP",
          "crv": "Ed25519",
          "alg": "EdDSA",
          "use": "sig",
          "kid": "OE_x89FAZvHnzOd9gMU0zFW-AhHTESAL5cV5aL0dqyw",
          "x": "16JuFxlu7PYOFULk_W6t3sZO4NF8Yj-2QpBxCAiXQfs"
        }
      ]
    }
    

公共 JWK 集字段参考

JWK 集 keys 数组中的每个公钥对象都支持以下字段:

字段 值 说明
kty "OKP" 密钥类型。必须是 "OKP"(八位字节密钥对),如 RFC 8037 中所定义。
crv "Ed25519" 加密曲线。必须为 "Ed25519"。
alg "EdDSA" 要与密钥搭配使用的算法。必须为 "EdDSA"。
use "sig" 公钥的预期用途。设为 "sig"(签名)。
x "BASE64URL_PUBLIC_KEY" 32 字节原始 Ed25519 公钥,使用未填充的 base64url(RFC 4648 第 5 节)进行编码。
kid "KEY_ID" 与签名请求中的 keyid 参数匹配的唯一密钥标识符。使用 RFC 7638 SHA-256 JWK 指纹(未填充的 base64url),以实现跨供应商兼容性。
nbf UNIX_SECONDS 可选。Unix 时间戳(以秒为单位),在此时间戳之前,密钥无效。
exp UNIX_SECONDS 可选。密钥到期后的 Unix 时间戳(以秒为单位)。验证方会拒绝已过期的密钥。

托管公钥目录

验证程序会从代理网域中一个众所周知的 HTTPS 端点提取您的公钥。

端点和标头要求

如需托管密钥目录,请将 Web 服务器配置为满足以下要求:

  • 网址路径:在 https://YOUR_AGENT_DOMAIN/.well-known/http-message-signatures-directory 处提供 JWK 集 JSON。
  • 传输安全:通过 HTTPS 在默认端口 443 上提供端点。请勿将请求重定向到其他主机或架构。
  • 内容类型:将 Content-Type 响应标头设置为 application/http-message-signatures-directory+json(或 application/json)。
  • 缓存:使用 max-age 指令(例如 Cache-Control: public, max-age=3600)设置 Cache-Control 响应标头。
  • 密钥轮替:在使用新私钥对签名请求进行签名之前,请将其公开 JWK 添加到 keys 数组中,并等待 Cache-Control TTL 过期。从 keys 数组中移除已撤消或已过期的密钥。

以下示例展示了来自 GET /.well-known/http-message-signatures-directory 的 HTTP 响应:

HTTP/1.1 200 OK
Content-Type: application/http-message-signatures-directory+json
Cache-Control: public, max-age=3600

{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "alg": "EdDSA",
      "use": "sig",
      "kid": "OE_x89FAZvHnzOd9gMU0zFW-AhHTESAL5cV5aL0dqyw",
      "x": "16JuFxlu7PYOFULk_W6t3sZO4NF8Yj-2QpBxCAiXQfs"
    }
  ]
}

为出站 HTTP 请求签名

如需使用 WBA 对出站 HTTPS 请求进行身份验证,请附加三个必需的标头:Signature-Agent、Signature-Input 和 Signature。

HTTP 标头

  1. Signature-Agent:以带引号的字符串形式指定密钥目录的 HTTPS 来源:

    Signature-Agent: "https://agent.example.com"
    
  2. Signature-Input:将签名标签(例如 sig1)映射到所涵盖的 HTTP 组件和签名元数据参数的内部列表:

    Signature-Input: sig1=("@authority" "signature-agent");created=1772400000;expires=1772400300;keyid="OE_x89FAZvHnzOd9gMU0zFW-AhHTESAL5cV5aL0dqyw";tag="web-bot-auth"
    

    包含以下涵盖的组件和参数:

    • 涵盖的组件:
      • "@authority":必填。将签名绑定到目标主机(例如 www.example.com)。
      • "signature-agent":必填。将签名绑定到 Signature-Agent 标头。
    • 元数据参数:
      • created:当前 Unix 时间戳(以秒为单位,整数)。
      • expires:到期 Unix 时间戳(以秒为单位,整数)。将 expires 设置为 created 之后最多 300 秒(5 分钟)。
      • keyid:JWK 集中的公钥 kid 字符串。
      • tag:必须为 "web-bot-auth"。
  3. Signature:将相同的签名标签 (sig1) 映射到采用 RFC 8941 字节序列格式(用 : 冒号括起来)的 base64 编码 Ed25519 签名:

    Signature: sig1=:BASE64_ENCODED_SIGNATURE:
    

构建签名基础

在签名之前,请构建 RFC 9421 第 2.5 节中定义的规范签名基字符串。每个涵盖的组件都以小写形式单独显示在一行中,后跟 @signature-params 行,末尾没有换行符:

"@authority": www.example.com
"signature-agent": "https://agent.example.com"
"@signature-params": ("@authority" "signature-agent");created=1772400000;expires=1772400300;keyid="OE_x89FAZvHnzOd9gMU0zFW-AhHTESAL5cV5aL0dqyw";tag="web-bot-auth"

使用您的 Ed25519 私钥为此签名基础字符串的 UTF-8 字节签名。

Go 示例

以下示例会加载由 OpenSSL 生成的 private-key.pem 文件,并使用 Go 的标准 crypto/ed25519 软件包和 github.com/dunglas/httpsfv 对出站 *http.Request 进行签名:

package main

import (
    "crypto/ed25519"
    "crypto/x509"
    "encoding/base64"
    "encoding/pem"
    "fmt"
    "net/http"
    "strings"
    "time"

    "github.com/dunglas/httpsfv"
)

// loadPrivateKey parses a PKCS#8 PEM-encoded Ed25519 private key
// generated by OpenSSL.
func loadPrivateKey(pemBytes []byte) (ed25519.PrivateKey, error) {
    // Decode the PEM armor to get the raw DER bytes.
    block, _ := pem.Decode(pemBytes)
    if block == nil {
        return nil, fmt.Errorf("failed to decode PEM block")
    }

    // OpenSSL generates private keys in PKCS#8 format by default.
    key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
    if err != nil {
        return nil, err
    }

    // Confirm that the parsed key is an Ed25519 private key.
    privKey, ok := key.(ed25519.PrivateKey)
    if !ok {
        return nil, fmt.Errorf("not an Ed25519 private key")
    }
    return privKey, nil
}

// signRequestEd25519 signs req with WBA headers.
func signRequestEd25519(
    req *http.Request,
    privKey ed25519.PrivateKey,
    keyID string,
) error {
    const (
        // sigLabel links the Signature-Input and Signature entries.
        sigLabel = "sig1"
        // agentURL is the HTTPS origin where your JWK Set is hosted.
        agentURL = "https://agent.example.com"
    )

    // 1. Set the Signature-Agent header as a quoted string.
    sigAgentHeader := fmt.Sprintf("%q", agentURL)
    req.Header.Set("Signature-Agent", sigAgentHeader)

    // 2. List the HTTP components to cover in the signature.
    // Always cover "@authority" (the target host) and "signature-agent".
    authority := strings.ToLower(req.URL.Host)
    items := []httpsfv.Item{
        httpsfv.NewItem("@authority"),
        httpsfv.NewItem("signature-agent"),
    }
    baseLines := []string{
        fmt.Sprintf(`"@authority": %s`, authority),
        fmt.Sprintf(`"signature-agent": %s`, sigAgentHeader),
    }

    // If the optional Agent-Context header is set, sign it too.
    if agentCtx := req.Header.Get("Agent-Context"); agentCtx != "" {
        items = append(items, httpsfv.NewItem("agent-context"))
        line := fmt.Sprintf(`"agent-context": %s`, agentCtx)
        baseLines = append(baseLines, line)
    }

    // 3. Set the signature metadata parameters.
    // Set expires to 300 seconds (5 minutes) after created.
    now := time.Now().Unix()
    params := httpsfv.NewParams()
    params.Add("created", now)
    params.Add("expires", now+300)
    params.Add("keyid", keyID)
    params.Add("tag", "web-bot-auth")

    // Serialize the covered components and parameters for Signature-Input.
    innerList := httpsfv.InnerList{
        Items:  items,
        Params: params,
    }
    sigParamsStr, err := httpsfv.Marshal(innerList)
    if err != nil {
        return err
    }

    // 4. Build the RFC 9421 signature base string.
    // Join each covered component line and @signature-params with "\n".
    // Don't add a trailing newline after the @signature-params line.
    paramLine := fmt.Sprintf(`"@signature-params": %s`, sigParamsStr)
    baseLines = append(baseLines, paramLine)
    sigBase := strings.Join(baseLines, "\n")

    // 5. Sign the UTF-8 bytes of the signature base with your private key.
    sigBytes := ed25519.Sign(privKey, []byte(sigBase))
    sigB64 := base64.StdEncoding.EncodeToString(sigBytes)

    // 6. Attach the Signature-Input and Signature headers to the request.
    req.Header.Set("Signature-Input", fmt.Sprintf("%s=%s", sigLabel, sigParamsStr))
    req.Header.Set("Signature", fmt.Sprintf("%s=:%s:", sigLabel, sigB64))
    return nil
}

添加实验性 Agent-Context 标头

多租户平台(例如托管式浏览器提供商或云代理运行时)通常会使用单个共享签名密钥对来自多个客户代理或租户的出站请求进行签名。默认情况下,WBA 仅识别签名网域 (Signature-Agent),因此使用该密钥签名的所有流量都具有相同的信誉。

当您添加并签署 Agent-Context 标头时,reCAPTCHA 可以针对在您的平台上共享通用签名密钥的不同产品、项目、用户或会话分别评估声誉。

设置 Agent-Context 标头的格式

将 Agent-Context 格式化为 RFC 8941 结构化字段字典,其中每个键都是小写令牌,每个值都是带引号的 ASCII 字符串:

Agent-Context: product="ResearchBot", project="prj_89a0f", user="usr_29df3", session="sess_88b1"

所有字段均为选填。仅包含适用于您的架构的字段:

字段 说明 示例
product 发出请求的代理的应用名称或商业名称。 product="ResearchBot"
project 宿主平台上的假名化账号、工作区或租户项目标识符。 project="prj_89a0f"
user 客户或最终用户的假名化标识符(例如不透明的 UUID 或加盐哈希)。对于来自同一用户的请求,请保持此值稳定,以建立声誉,并且切勿包含真实的个人身份信息。 user="usr_29df3"
session 单个任务、工作流运行或浏览会话的临时不透明标识符。 session="sess_88b1"

为 Agent-Context 标头签名

仅当您在 WBA 签名中添加 "agent-context" 时,reCAPTCHA 才会使用 Agent-Context 进行声誉隔离。未签名的 Agent-Context 标头会被忽略。

如需对 Agent-Context 标头进行签名,请完成以下步骤:

  1. 在调用 signRequestEd25519 之前,请在出站 *http.Request 上设置 Agent-Context 标头:

    req.Header.Set("Agent-Context", `product="ResearchBot", project="prj_89a0f", user="usr_29df3", session="sess_88b1"`)
    
  2. 在 Signature-Input 组件列表和签名基本字符串中添加 "agent-context"(由 Go 示例自动处理):

    "@authority": www.example.com
    "signature-agent": "https://agent.example.com"
    "agent-context": product="ResearchBot", project="prj_89a0f", user="usr_29df3", session="sess_88b1"
    "@signature-params": ("@authority" "signature-agent" "agent-context");created=1772400000;expires=1772400300;keyid="OE_x89FAZvHnzOd9gMU0zFW-AhHTESAL5cV5aL0dqyw";tag="web-bot-auth"
    

以下示例展示了使用 Agent-Context 签名的完整 HTTP 请求:

GET /api/data HTTP/1.1
Host: www.example.com
Signature-Agent: "https://agent.example.com"
Agent-Context: product="ResearchBot", project="prj_89a0f", user="usr_29df3", session="sess_88b1"
Signature-Input: sig1=("@authority" "signature-agent" "agent-context");created=1772400000;expires=1772400300;keyid="OE_x89FAZvHnzOd9gMU0zFW-AhHTESAL5cV5aL0dqyw";tag="web-bot-auth"
Signature: sig1=:BASE64_ENCODED_SIGNATURE:

后续步骤

使用 WBA 对请求进行签名可证明对代理网域的加密控制,但网站还需要有关代理运营者及其用途的元数据。