웹 크롤러 인증으로 아웃바운드 요청 서명

웹 크롤러 인증 (WBA) 프로토콜을 사용하면 HTTP 메시지 서명 (RFC 9421)을 사용하여 에이전트 또는 크롤러의 ID를 암호화 방식으로 증명할 수 있습니다.

발신 요청에 서명하면 Google Cloud Fraud Defense (reCAPTCHA의 발전된 형태)와 같은 서비스에서 IP 주소나 User-Agent 문자열에만 의존하지 않고 에이전트를 직접 확인할 수 있습니다.

WBA로 에이전트를 인증하려면 다음 작업을 완료하세요.

  1. Ed25519 키 쌍을 생성하고 공개 키를 JSON 웹 키 (JWK) 세트로 형식 지정합니다.
  2. 잘 알려진 HTTPS 엔드포인트에서 공개 키 디렉터리를 호스팅합니다.
  3. 비공개 키로 각 아웃바운드 HTTP 요청에 서명합니다.
  4. 선택사항: 실험용 Agent-Context 헤더에 서명하여 서명 키를 공유하는 테넌트 간에 평판을 격리합니다.

Ed25519 키 쌍 생성

WBA에는 Ed25519 (RFC 8032) 비대칭 키 쌍이 필요합니다. 비공개 키를 비공개로 유지하여 아웃바운드 HTTP 요청에 서명하고 JSON 웹 키 (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" 키 유형입니다. RFC 8037에 정의된 "OKP" (옥텟 키 쌍)이어야 합니다.
crv "Ed25519" 암호화 곡선입니다. "Ed25519"이어야 합니다.
alg "EdDSA" 키와 함께 사용하려는 알고리즘입니다. "EdDSA"이어야 합니다.
use "sig" 공개 키의 의도된 용도입니다. "sig" (서명)로 설정합니다.
x "BASE64URL_PUBLIC_KEY" 패딩되지 않은 base64url (RFC 4648 섹션 5)을 사용하여 인코딩된 32바이트 원시 Ed25519 공개 키입니다.
kid "KEY_ID" 서명된 요청의 keyid 매개변수와 일치하는 고유 키 식별자입니다. 교차 공급업체 호환성을 위해 RFC 7638 SHA-256 JWK 지문 (패딩되지 않은 base64url)을 사용합니다.
nbf UNIX_SECONDS (선택사항) 키가 유효하지 않은 상태가 되기 전의 Unix 타임스탬프(초)입니다.
exp UNIX_SECONDS (선택사항) 키가 만료되는 Unix 타임스탬프(초)입니다. 검증자는 만료된 키를 거부합니다.

공개 키 디렉터리 호스팅

확인자는 에이전트 도메인의 잘 알려진 HTTPS 엔드포인트에서 공개 키를 가져옵니다.

엔드포인트 및 헤더 요구사항

키 디렉터리를 호스팅하려면 다음 요구사항을 충족하도록 웹 서버를 구성합니다.

  • URL 경로: https://YOUR_AGENT_DOMAIN/.well-known/http-message-signatures-directory에서 JWK 세트 JSON을 제공합니다.
  • 전송 보안: 기본 포트 443에서 HTTPS를 통해 엔드포인트를 제공합니다. 요청을 다른 호스트나 스키마로 리디렉션하지 마세요.
  • 콘텐츠 유형: 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 타임스탬프 (초 단위, 정수)입니다. created 후 expires를 최대 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 헤더 서명

reCAPTCHA는 WBA 서명에 "agent-context"를 포함하는 경우에만 평판 격리에 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로 요청에 서명하면 에이전트 도메인의 암호화 제어가 증명되지만, 웹사이트에는 에이전트를 운영하는 주체와 에이전트의 목적에 관한 메타데이터도 필요합니다.