设置发布商提供的 IP

浏览器隐私代理和 IP 保护设置可能会在向 Google Ad Manager 发出的第三方广告请求中遮盖客户端 IP 地址。遮盖 IP 地址可能会降低地理位置定位准确性和广告相关性。由于您的 Web 服务器或内容分发网络 (CDN) 在第一方环境中与用户互动,因此您的服务器会收到用户的未遮盖 IP 地址。

借助发布商提供的 IP 功能,您可以在服务器上捕获用户未遮盖的 IPv4 或 IPv6 地址,使用混合公钥加密 (HPKE) 对该地址进行加密,然后将加密后的载荷传递给客户端上的 Google 发布商代码 (GPT)。

本页介绍了如何将发布商提供的 IP 与 GPT 集成。

实现服务器端工作流

为避免在浏览器中向第三方脚本公开用户的明文 IP 地址,请在 Web 服务器或 CDN 边缘工作器上执行以下步骤:

  • 执行所有 IP 捕获。
  • 提取公钥。
  • 执行 HPKE 加密。

捕获客户端 IP 地址

从 Web 服务器或 CDN 上的传入网页请求中提取客户端的未遮盖 IPv4 或 IPv6 地址:

  • 直接连接:读取传入 HTTP 连接的远程套接字地址。
  • 位于 CDN、负载均衡器或反向代理后面:从受信任的边缘代理读取客户端 IP 标头,例如 X-Forwarded-For 标头或 CDN 专用客户端 IP 标头。仅信任代理基础架构添加或替换的转发标头。当 X-Forwarded-For 标头包含多个以逗号分隔的地址时,请选择受信任的边缘代理添加的客户端 IP。

Ad Manager 支持以下地址:

  • IPv4,例如 203.0.113.42。
  • IPv6,例如 2001:db8::8a2e:370:7334。

仅传递裸 IP 地址字符串,不含端口号、括号、CIDR 前缀或周围的空格。

提取并缓存公钥

Google 会在以下端点将有效的 HPKE 加密密钥发布为 RFC 8037 JSON Web 密钥集 (JWKS):

https://www.gstatic.com/real-ip/ip_enc_key.json

向此端点发出的 GET 请求会返回一个 JSON 对象,其 keys 数组包含一个活跃密钥对象。使用该对象的 x 属性中的密钥来加密客户端 IP 地址。

以下示例会提取公钥,并在成功时提取 x 属性,在失败时抛出异常:

Java

private static final String KEY_ENDPOINT_URL = "https://www.gstatic.com/real-ip/ip_enc_key.json";

/**
 * Fetches the active X25519 public key from Google's key endpoint.
 *
 * <p>In production, cache the returned public key in memory and refresh it at least once every 24
 * hours rather than fetching it on every incoming request.
 *
 * @return The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
 */
public static String fetchPublicKey() throws IOException, InterruptedException {
  return fetchPublicKey(KEY_ENDPOINT_URL);
}

public static String fetchPublicKey(String endpointUrl) throws IOException, InterruptedException {
  HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
  HttpRequest request =
      HttpRequest.newBuilder()
          .uri(URI.create(endpointUrl))
          .timeout(Duration.ofSeconds(5))
          .GET()
          .build();
  HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
  if (response.statusCode() < 200 || response.statusCode() >= 300) {
    throw new IOException("Failed to fetch public key, HTTP status: " + response.statusCode());
  }
  JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
  JsonArray keys = json.getAsJsonArray("keys");
  if (keys == null || keys.isEmpty()) {
    throw new IOException("No keys found in response from " + endpointUrl);
  }
  JsonObject keyObj = keys.get(0).getAsJsonObject();
  if (!keyObj.has("x")) {
    throw new IOException("Key payload missing 'x' property: " + keyObj);
  }
  return keyObj.get("x").getAsString();
}

Python

KEY_ENDPOINT_URL = "https://www.gstatic.com/real-ip/ip_enc_key.json"


def fetch_public_key(url: str = KEY_ENDPOINT_URL) -> str:
  """Fetches the active X25519 public key from Google's key endpoint.

  In production, cache the returned public key in memory and refresh it at
  least once every 24 hours rather than fetching it on every incoming request.

  Args:
    url: URL of the public key endpoint.

  Returns:
    The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
  """
  with urllib.request.urlopen(url, timeout=5) as response:
    data = json.loads(response.read().decode("utf-8"))
  keys = data.get("keys", [])
  if not keys:
    raise ValueError(f"No keys found in response from {url}")
  key_payload = keys[0].get("x")
  if not key_payload:
    raise ValueError(f"Key payload missing 'x' property: {keys[0]}")
  return key_payload


JavaScript

const KEY_ENDPOINT_URL = 'https://www.gstatic.com/real-ip/ip_enc_key.json';

/**
 * Fetches the active X25519 public key from Google's key endpoint.
 *
 * In production, cache the returned public key in memory and refresh it at
 * least once every 24 hours rather than fetching it on every incoming request.
 *
 * @param {string=} url URL of the public key endpoint.
 * @return {!Promise<string>} The unpadded Base64URL-encoded 32-byte X25519
 *     public key ('x').
 */
async function fetchPublicKey(url = KEY_ENDPOINT_URL) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(
      `Failed to fetch public key, HTTP status: ${response.status}`,
    );
  }
  const data = await response.json();
  if (!data.keys || data.keys.length === 0) {
    throw new Error(`No keys found in response from ${url}`);
  }
  const keyPayload = data.keys[0].x;
  if (!keyPayload) {
    throw new Error(
      `Key payload missing 'x' property: ${JSON.stringify(data.keys[0])}`,
    );
  }
  return keyPayload;
}

TypeScript

const KEY_ENDPOINT_URL = 'https://www.gstatic.com/real-ip/ip_enc_key.json';

interface JwkKey {
  kty?: string;
  crv?: string;
  x?: string;
  kid?: string;
}

interface KeyEndpointResponse {
  keys?: JwkKey[];
}

/**
 * Fetches the active X25519 public key from Google's key endpoint.
 *
 * In production, cache the returned public key in memory and refresh it at
 * least once every 24 hours rather than fetching it on every incoming request.
 *
 * @param url URL of the public key endpoint.
 * @return The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
 */
export async function fetchPublicKey(
  url: string = KEY_ENDPOINT_URL,
): Promise<string> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(
      `Failed to fetch public key, HTTP status: ${response.status}`,
    );
  }
  const data = (await response.json()) as KeyEndpointResponse;
  if (!data.keys || data.keys.length === 0) {
    throw new Error(`No keys found in response from ${url}`);
  }
  const keyPayload = data.keys[0].x;
  if (!keyPayload) {
    throw new Error(
      `Key payload missing 'x' property: ${JSON.stringify(data.keys[0])}`,
    );
  }
  return keyPayload;
}

如果成功,响应将包含 keys 数组,其中包含具有 x 属性的密钥对象:

{
  "keys": [
    {
      "kty": "OKP",
      "crv": "X25519",
      "x": "7bws5TIHOUrcJNofEubW3DPW8zR9EtjFNLm_Grv98TA",
      "kid": "EZMLFw"
    }
  ]
}

下表介绍了 keys 数组中密钥对象的属性:

属性 说明
crv 椭圆曲线标识符。始终为 X25519。
kid 未填充的 base64url 编码密钥标识符,在 Google 轮替密钥时会发生变化。
kty 密钥类型。始终为 OKP(八位字节密钥对)。
x 用于加密的未填充 base64url 编码的 32 字节原始 X25519 公钥。

将提取的公钥缓存在服务器的内存中,并至少每 24 小时刷新一次密钥,或根据 HTTP Cache-Control 响应标头更频繁地刷新。不要在每次收到页面请求时都提取公钥。Google 会保留轮替后的私钥。使用过去 24 小时内缓存的公钥加密的载荷在密钥轮替期间会继续解密,不会中断。

加密 IP 地址

使用 HPKE (RFC 9180) 和在提取并缓存公钥中检索到的已解码 32 字节 X25519 公钥 x,对客户端 IP 地址字符串的 UTF-8 字节表示法进行加密。

以下示例对 IP 地址字符串进行加密:

Java

此示例使用 Tink(com.google.crypto.tink:tink 1.16.0 或更高版本):

static {
  try {
    HybridConfig.register();
  } catch (GeneralSecurityException e) {
    throw new ExceptionInInitializerError(e);
  }
}

/**
 * Encrypts an IP address using HPKE (DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-256-GCM).
 *
 * @param pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
 * @param ipStr Plaintext IPv4 or IPv6 address string (for example, "203.0.113.42").
 * @return Unpadded Base64URL-encoded payload (enc || ciphertext).
 */
@AccessesPartialKey
public static String encryptIp(String pubXB64Url, String ipStr) throws GeneralSecurityException {
  // 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
  byte[] pubBytes = Base64.getUrlDecoder().decode(pubXB64Url);
  if (pubBytes.length != 32) {
    throw new IllegalArgumentException(
        "Public key must be exactly 32 bytes, got " + pubBytes.length);
  }

  // 2. Configure HPKE parameters (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
  //    AES_256_GCM, NO_PREFIX).
  HpkeParameters parameters =
      HpkeParameters.builder()
          .setKemId(HpkeParameters.KemId.DHKEM_X25519_HKDF_SHA256)
          .setKdfId(HpkeParameters.KdfId.HKDF_SHA256)
          .setAeadId(HpkeParameters.AeadId.AES_256_GCM)
          .setVariant(HpkeParameters.Variant.NO_PREFIX)
          .build();

  HpkePublicKey publicKey =
      HpkePublicKey.create(parameters, Bytes.copyFrom(pubBytes), /* idRequirement= */ null);

  // 3. Encrypt the UTF-8 IP address bytes. With Variant.NO_PREFIX, Tink
  //    outputs raw (enc || ciphertext_with_tag).
  KeysetHandle handle =
      KeysetHandle.newBuilder()
          .addEntry(KeysetHandle.importKey(publicKey).withRandomId().makePrimary())
          .build();

  HybridEncrypt encryptor = handle.getPrimitive(RegistryConfiguration.get(), HybridEncrypt.class);
  byte[] payload =
      encryptor.encrypt(ipStr.getBytes(StandardCharsets.UTF_8), /* contextInfo= */ new byte[0]);

  // 4. Return unpadded Base64URL-encoded payload.
  return Base64.getUrlEncoder().withoutPadding().encodeToString(payload);
}

Python

此示例使用 pyhpke:

def encrypt_ip(pub_x_b64url: str, ip_str: str) -> str:
  """Encrypts an IP address using HPKE (RFC 9180).

  Args:
    pub_x_b64url: Base64URL-encoded 32-byte X25519 public key ('x').
    ip_str: Plaintext IPv4 or IPv6 address string (for example, "203.0.113.42").

  Returns:
    Unpadded Base64URL-encoded payload (enc || ciphertext).
  """
  # 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
  pub_bytes = base64.urlsafe_b64decode(
      pub_x_b64url + "=" * (-len(pub_x_b64url) % 4)
  )
  if len(pub_bytes) != 32:
    raise ValueError(
        f"Public key must be exactly 32 bytes, got {len(pub_bytes)}"
    )

  # 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
  #    AES256_GCM).
  suite = CipherSuite.new(
      KEMId.DHKEM_X25519_HKDF_SHA256, KDFId.HKDF_SHA256, AEADId.AES256_GCM
  )
  public_key = suite.kem.deserialize_public_key(pub_bytes)

  # 3. Create a fresh sender context per encryption (you may cache 'suite' and
  #    'public_key', but never reuse 'sender' across multiple seal() calls) and
  #    package (enc || ciphertext).
  enc, sender = suite.create_sender_context(public_key)
  ciphertext = sender.seal(ip_str.encode("utf-8"))

  # 4. Return unpadded Base64URL-encoded payload.
  return base64.urlsafe_b64encode(enc + ciphertext).decode("utf-8").rstrip("=")


JavaScript

此示例使用 @hpke/core:

/**
 * Encrypts an IP address using HPKE (RFC 9180).
 *
 * @param {string} pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
 * @param {string} ipStr Plaintext IPv4 or IPv6 address string (for example,
 *     "203.0.113.42").
 * @return {!Promise<string>} Unpadded Base64URL-encoded payload
 *     (enc || ciphertext).
 */
async function encryptIp(pubXB64Url, ipStr) {
  // 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
  const pubBytes = Buffer.from(pubXB64Url, 'base64url');
  if (pubBytes.length !== 32) {
    throw new Error(
      `Public key must be exactly 32 bytes, got ${pubBytes.length}`,
    );
  }

  // 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
  //    AES_256_GCM).
  const suite = new CipherSuite({
    kem: new DhkemX25519HkdfSha256(),
    kdf: new HkdfSha256(),
    aead: new Aes256Gcm(),
  });
  const rawPubKey = new Uint8Array(pubBytes).buffer;
  const publicKey = await suite.kem.importKey('raw', rawPubKey, true);

  // 3. Encrypt the UTF-8 IP address bytes and package (enc || ciphertext).
  const plaintext = new Uint8Array(Buffer.from(ipStr, 'utf8')).buffer;
  const {enc, ct} = await suite.seal(
    {recipientPublicKey: publicKey},
    plaintext,
  );

  // 4. Return unpadded Base64URL-encoded payload.
  return Buffer.concat([Buffer.from(enc), Buffer.from(ct)]).toString(
    'base64url',
  );
}

TypeScript

此示例使用 @hpke/core:

/**
 * Encrypts an IP address using HPKE (RFC 9180).
 *
 * @param pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
 * @param ipStr Plaintext IPv4 or IPv6 address string (for example,
 *     "203.0.113.42").
 * @return Unpadded Base64URL-encoded payload (enc || ciphertext).
 */
export async function encryptIp(
  pubXB64Url: string,
  ipStr: string,
): Promise<string> {
  // 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
  const pubBytes = Buffer.from(pubXB64Url, 'base64url');
  if (pubBytes.length !== 32) {
    throw new Error(
      `Public key must be exactly 32 bytes, got ${pubBytes.length}`,
    );
  }

  // 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
  //    AES_256_GCM).
  const suite = new CipherSuite({
    kem: new DhkemX25519HkdfSha256(),
    kdf: new HkdfSha256(),
    aead: new Aes256Gcm(),
  });
  const rawPubKey = new Uint8Array(pubBytes).buffer;
  const publicKey = await suite.kem.importKey('raw', rawPubKey, true);

  // 3. Encrypt the UTF-8 IP address bytes and package (enc || ciphertext).
  const plaintext = new Uint8Array(Buffer.from(ipStr, 'utf8')).buffer;
  const {enc, ct} = await suite.seal(
    {recipientPublicKey: publicKey},
    plaintext,
  );

  // 4. Return unpadded Base64URL-encoded payload.
  return Buffer.concat([Buffer.from(enc), Buffer.from(ct)]).toString(
    'base64url',
  );
}

使用以下加密套件和参数配置 HPKE 库:

参数 值
密钥封装机制 (KEM) DHKEM(X25519, HKDF-SHA256) (0x0020)
密钥派生函数 (KDF) HKDF-SHA256 (0x0001)
关联数据加密的身份验证加密 (AEAD) AES-256-GCM (0x0002)
模式 基础 (0x00)
应用信息 (info) 空 ("")
关联的数据 (aad) 空 ("")

将加密输出格式化为 32 字节封装临时公钥 enc 和 AEAD 密文(包括 16 字节 GCM 身份验证标记)的原始串联,不含 Tink 或自定义标头前缀。将结果编码为未填充的 base64url 字符串。

使用新的临时密钥对将每个载荷加密为独立的单次 HPKE 消息。如果您的 HPKE 库使用有状态发送者上下文,请为每次加密创建一个新上下文,而不是在多条消息中重复使用。为每个网页请求加密客户端 IP 地址,或为每个 IP 地址缓存生成的 base64url 密文字符串,并设置较短的存活时间 (TTL)。

实现客户端工作流

服务器生成经过加密的 base64url IP 载荷后,请先将该载荷传递给客户端上的 GPT,然后再请求广告。

将加密的 IP 传递给 GPT

使用 PageSettingsConfig 上的 ipAddress 属性将加密的 IP 字符串传递给 googletag.setConfig 方法。

以下示例在启用服务和显示广告位之前,将服务器生成的加密 IP 载荷插入到 googletag.setConfig 方法中:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Publisher-provided IP example</title>
    <script src="https://securepubads.g.doubleclick.net/tag/js/gpt.js" crossorigin="anonymous" async></script>
    <script>
      window.googletag = window.googletag || {cmd: []};

      // Inject the base64url-encoded encrypted IP generated by your server.
      const encryptedClientIp = 'ENCRYPTED_IP_PAYLOAD';

      googletag.cmd.push(function() {
        // Configure the encrypted IP before enabling services.
        googletag.setConfig({
          ipAddress: encryptedClientIp,
        });

        googletag
            .defineSlot('/6355419/Travel/Europe/France/Paris', [300, 250], 'banner-ad')
            .addService(googletag.pubads());
        googletag.enableServices();
      });
    </script>
  </head>
  <body>
    <div id="banner-ad" style="width: 300px; height: 250px;"></div>
    <script>
      googletag.cmd.push(function() {
        googletag.display('banner-ad');
      });
    </script>
  </body>
</html>

将 ENCRYPTED_IP_PAYLOAD 替换为服务器生成的未填充的 base64url 编码字符串。

如需清除页面上之前配置的 IP 地址,请将 ipAddress 属性设置为 null 值:

googletag.setConfig({ ipAddress: null });

验证集成

如需验证 GPT 是否发送了加密的 IP 载荷以及 Ad Manager 是否解密了该载荷,请使用浏览器的开发者工具和 Google 发布商控制台:

浏览器开发者工具

  1. 打开浏览器的开发者工具。
  2. 选择网络标签页。
  3. 过滤向 /gampad/ads 发出的广告请求。
  4. 确认请求网址包含非空 ips 查询参数。由于 GPT 会在发送广告请求之前进一步对 ipAddress 值进行编码,因此 ips 参数与您的加密字符串不完全一致。

Google 发布商控制台

  1. 如需打开控制台,请按照 Google 发布商控制台指南中的步骤操作。
  2. 选择页面请求标签页。
  3. 确认未显示任何 ipAddress 警告。如果载荷存在问题,控制台会记录以下警告之一:

    • 如果传递给 ipAddress 属性的值不是有效的 base64 或 base64url 字符串(长度介于 67 到 124 个字符之间),GPT 会忽略该值,从广告请求中省略 ips 参数,并记录以下警告:

      Invalid value encountered when calling: googletag.setConfig: {"ipAddress":"..."}. (string) ... was found at PageSettingsConfig.ipAddress. Invalid IP address format.
      
    • 如果 Ad Manager 无法解密提供的 IP 载荷,或者解密后的明文不是有效的 IPv4 或 IPv6 地址,控制台会记录以下警告并回退到网络请求 IP 地址:

      Decryption failed on the provided encrypted client IP (supplied via googletag.setConfig({ipAddress: ...})).