חתימה על בקשות יוצאות באמצעות Web Bot Auth

פרוטוקול Web Bot Auth‏ (WBA) מאפשר להוכיח באופן קריפטוגרפי את הזהות של הנציג או הבוט באמצעות חתימות HTTP על הודעות (RFC 9421).

כשאתם חותמים על בקשות יוצאות, שירותים כמו Google Cloud Fraud Defense (הגרסה המתקדמת של reCAPTCHA) יכולים לאמת את הנציג שלכם ישירות, בלי להסתמך רק על כתובות IP או על מחרוזות User-Agent.

כדי לאמת את הנציג באמצעות WBA, צריך לבצע את הפעולות הבאות:

  1. יוצרים זוג מפתחות Ed25519 ומגדירים את הפורמט של המפתח הציבורי כקבוצת מפתחות אינטרנט בפורמט JSON‏ (JWK).
  2. מארחים את ספריית המפתחות הציבוריים בנקודת קצה (endpoint) מוכרת של HTTPS.
  3. חותמים על כל בקשת HTTP יוצאת באמצעות המפתח הפרטי.
  4. אופציונלי: חותמים על הכותרת הניסיונית Agent-Context כדי לבודד את המוניטין בין דיירים שמשתמשים באותו מפתח חתימה.

יצירת זוג מפתחות Ed25519

כדי להשתמש ב-WBA, צריך זוג מפתחות אסימטריים מסוג Ed25519 (RFC 8032). אתם שומרים בסוד את המפתח הפרטי כדי לחתום על בקשות HTTP יוצאות, ומפרסמים את המפתח הציבורי ב-JSON Web Key (JWK) Set (RFC 7517).

יצירת זוג מפתחות באמצעות OpenSSL ו-jwker

אתם יכולים ליצור זוג מפתחות Ed25519 באמצעות OpenSSL ולהמיר את המפתח הציבורי ל-JWK באמצעות הכלי jwker בקוד פתוח:

  1. יוצרים מפתח פרטי מסוג Ed25519 בפורמט PEM:

    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. ממירים את המפתח הציבורי בפורמט PEM לקובץ JWK באמצעות jwker:

    jwker public-key.pem public-key.jwk
    

    הפקודה הזו כותבת אובייקט JWK יחיד אל public-key.jwk:

    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "16JuFxlu7PYOFULk_W6t3sZO4NF8Yj-2QpBxCAiXQfs"
    }
    
  5. עוטפים את אובייקט ה-JWK בתוך מערך keys של קבוצת JWK‏ (jwks.json) ומוסיפים את השדות הנדרשים alg,‏ use ו-kid:

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

הפניה לשדה של קבוצת מפתחות JWK ציבוריים

כל אובייקט של מפתח ציבורי במערך keys של קבוצת ה-JWK תומך בשדות הבאים:

שדה ערך תיאור
kty "OKP" סוג המפתח. חייב להיות "OKP" (זוג מפתחות אוקטט) כפי שמוגדר ב-RFC 8037.
crv "Ed25519" עקומה קריפטוגרפית. חייב להיות "Ed25519".
alg "EdDSA" האלגוריתם שמיועד לשימוש עם המפתח. חייב להיות "EdDSA".
use "sig" השימוש המיועד במפתח הציבורי. מגדירים את הערך "sig" (חתימה).
x "BASE64URL_PUBLIC_KEY" המפתח הציבורי הגולמי של Ed25519 בגודל 32 בייטים, מקודד באמצעות base64url ללא ריפוד (RFC 4648 סעיף 5).
kid "KEY_ID" מזהה מפתח ייחודי שתואם לפרמטר keyid בבקשות החתומות. כדי להבטיח תאימות בין ספקים, צריך להשתמש בטביעת האצבע של JWK SHA-256 בתקן RFC 7638 (base64url ללא ריפוד).
nbf UNIX_SECONDS זה שינוי אופציונלי. חותמת זמן של Unix בשניות, שהמפתח לא תקף לפני שהיא חולפת.
exp UNIX_SECONDS זה שינוי אופציונלי. חותמת זמן של מערכת Unix בשניות, שאחריהן תוקף המפתח יפוג. גורמים מאמתים דוחים מפתחות שפג תוקפם.

אירוח של ספריית המפתחות הציבוריים

הגורמים המאמתים מאחזרים את המפתחות הציבוריים שלכם מנקודת קצה (endpoint) ידועה של HTTPS בדומיין של הנציג.

דרישות לגבי נקודות קצה וכותרות

כדי לארח את ספריית המפתחות, צריך להגדיר את שרת האינטרנט כך שיעמוד בדרישות הבאות:

  • נתיב כתובת ה-URL: הצגת קובץ ה-JSON של קבוצת מפתחות ה-JWK בכתובת https://YOUR_AGENT_DOMAIN/.well-known/http-message-signatures-directory.
  • אבטחת תעבורת נתונים: צריך להציג את נקודת הקצה (endpoint) דרך HTTPS ביציאת ברירת המחדל 443. אל תפנו בקשות למארח או לסכימה אחרים.
  • סוג התוכן: מגדירים את כותרת התגובה Content-Type ל-application/http-message-signatures-directory+json (או ל-application/json).
  • שמירה במטמון: מגדירים את כותרת התגובה Cache-Control באמצעות ההוראה max-age (לדוגמה, Cache-Control: public, max-age=3600).
  • רוטציית מפתחות: לפני שחותמים על בקשות חתומות באמצעות מפתח פרטי חדש, צריך להוסיף את ה-JWK הציבורי שלו למערך keys ולהמתין עד שתוקף ה-TTL של Cache-Control יפוג. מסירים מפתחות שבוטלו או שפג תוקפם מהמערך keys.

בדוגמה הבאה מוצגת תגובת HTTP מ-GET /.well-known/http-message-signatures-directory:

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 יוצאת

כדי לאמת בקשת HTTPS יוצאת באמצעות WBA, צריך לצרף שלוש כותרות חובה: 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: חותמת זמן של תפוגה ביוניקס בשניות (מספר שלם). מגדירים את expires ל-300 שניות (5 דקות) לכל היותר אחרי created.
      • ‫keyid: המחרוזת kid של המפתח הציבורי מקבוצת ה-JWK.
      • ‫tag: צריך להיות "web-bot-auth".
  3. ‫Signature: ממפה את אותה תווית חתימה (sig1) לחתימת Ed25519 בקידוד base64, בפורמט של רצף בייטים של RFC 8941 (עטוף בנקודתיים :):

    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"

חותמים על בייטים בקידוד UTF-8 של מחרוזת הבסיס של החתימה באמצעות המפתח הפרטי של Ed25519.

דוגמה ב-Go

בדוגמה הבאה נטען קובץ private-key.pem שנוצר על ידי OpenSSL, ונחתמת הודעת *http.Request יוצאת באמצעות החבילה הרגילה crypto/ed25519 של Go ו-github.com/dunglas/httpsfv:

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 מזהה פסאודונימי של חשבון, סביבת עבודה או פרויקט של דייר (tenant) בפלטפורמה המארחת. project="prj_89a0f"
user מזהה פסאודונימי (כמו UUID אטום או גיבוב מומלח) של הלקוח או משתמש הקצה. כדי לבנות מוניטין, הערך הזה צריך להיות זהה בכל הבקשות מאותו משתמש, ואסור לכלול בו פרטים אישיים מזהים (PII) אמיתיים. user="usr_29df3"
session מזהה זמני ואטום למשימה אחת, להפעלה של תהליך עבודה או לסשן גלישה. session="sess_88b1"

חתימה על הכותרת Agent-Context

מערכת reCAPTCHA משתמשת ב-Agent-Context רק לבידוד מוניטין כשכוללים את "agent-context" בחתימת ה-WBA. המערכת מתעלמת מכותרות Agent-Context לא חתומות.

כדי לחתום על הכותרת Agent-Context, מבצעים את השלבים הבאים:

  1. מגדירים את הכותרת Agent-Context ב-*http.Request היוצא לפני שקוראים ל-signRequestEd25519:

    req.Header.Set("Agent-Context", `product="ResearchBot", project="prj_89a0f", user="usr_29df3", session="sess_88b1"`)
    
  2. כוללים את "agent-context" ברשימת הרכיבים של Signature-Input ובמחרוזת הבסיס של החתימה (מטופל באופן אוטומטי על ידי הדוגמה של 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"
    

בדוגמה הבאה מוצגת בקשת HTTP מלאה שנחתמה באמצעות Agent-Context:

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 מוכיחה שליטה קריפטוגרפית בדומיין של הסוכן, אבל אתרים צריכים גם מטא-נתונים על מי שמפעיל את הסוכן ועל המטרה שלו.