הטמעה של צד נסמך

כדי להטמיע אימות של כתובות אימייל באתר, צריך לעדכן את תגי העיצוב של הטופס כדי לבקש את הטוקן ולהוסיף אימות בצד השרת לטוקנים נכנסים.

הרשמה לגרסת מקור לניסיון

כדי לאמת אתרים, צריך להגדיר באתר את גרסת המקור לניסיון.

החל מגרסה 154 של Chrome, יש תמיכה בתקופות ניסיון של מקורות צד שלישי, אבל יש אזהרה חשובה: המקור שרשום לתקופת הניסיון צריך להיות מאותו אתר כמו המנפיק. לדוגמה:

  • דומיין המנפיק: issuer.example
  • OT registrant: https://issuer.example
  • מקור JavaScript: https://issuer.example (או https://app.issuer.example עם התאמה לתת-דומיין)

הגדרת השדות בטופס

מוסיפים שדה מוסתר של טוקן לטופס שליחת האימייל:

<input
  type="email"
  name="email-address"
  autocomplete="email">
<input
  type="hidden"
  name="token"
  autocomplete="email-verification-token"
  nonce="rAnD0m-VaLuE">

דרישות לגבי השדות:

  • שדה האימייל: מגדירים את type="email" ואת autocomplete="email" כדי ש-Chrome יוכל למלא את הכתובת באופן אוטומטי ולזהות אותה.
  • מאפיינים של שדות טוקן:
    • הגדרת autocomplete="email-verification-token": Chrome מזהה את השדה הזה כדי לאכלס את הטוקן בשליחה.
    • הגדרה nonce="<VALUE>": האתר צריך לספק ערך חד-פעמי ייחודי שקשור לסשן כדי לאמת את שליחת הטופס.

אימות הטוקן לאימות כתובת האימייל (EVT)

כשהמשתמש שולח את הטופס, השרת שלכם מקבל את כתובת האימייל ואת הטוקן מהשדה המוסתר. שדה ריק של טוקן מציין שהדפדפן או הספק לא תומכים ב-EVP, או שהמשתמש דילג על האימות. אם זה קורה, צריך לחזור לתהליך האימות הקיים, כמו שליחת קוד אימות חד-פעמי או קישור קסם.

אם יש אסימון, צריך לאמת אותו באופן הבא:

  1. מנתחים את הטוקן באמצעות ספריית SD-JWT.
  2. אימות של ערכים צפויים וטענות לגבי סשנים.
  3. מאמתים את ההקצאה של DNS.
  4. גילוי מטא-נתונים של מנפיק ואחזור של JWKS.
  5. אימות חתימות קריפטוגרפיות וקישור מפתחות.

1. ניתוח הטוקן

האסימון הוא בפורמט RFC 9901: Selective Disclosure JWT (SD-JWT+KB). משתמשים בספריות המתאימות לפלטפורמה כדי לנתח ולאמת את הטוקן. לדוגמה, ב-Node אפשר להשתמש ב-@sd-jwt/core וב-jose. בפורמט הגולמי שלו, הוא נראה כך: אסימון JWT בחתימת מנפיק, שאחריו אפס או יותר גילויים, ומסתיים באסימון JWT של שיוך מפתח, כאשר כל רכיב מופרד באמצעות סימן הטילדה:

<Issuer-signed EVT>~<Disclosure 1>~...~<Disclosure N>~<Key Binding JWT>

ביישום הנוכחי, האסימון מכיל אפס גילוי נאות (<Issuer-signed EVT>~<Key Binding JWT>). עם זאת, יכול להיות שהמצב הזה ישתנה בעתיד.

מפענחים את האסימון באמצעות הספרייה:

import { decodeSdJwtSync } from "@sd-jwt/core";
import { createHash } from "node:crypto";
const hasher = (data, alg) =>
  createHash(alg === "sha-256" ? "sha256" : alg)
    .update(data)
    .digest();
const decoded = decodeSdJwtSync(rawToken, hasher);
const evtPayload = decoded.jwt.payload;
const kbPayload = decoded.kbJwt?.payload;

אם verifier.example מאמת את demo@provider.example, האסימון המפוענח נראה כך:

{
  "evtJwtDecodedHeader": {
    "typ": "evt+jwt",
    "alg": "EdDSA",
    "kid": "issuer-key-id"
  },
  "evtJwtDecodedPayload": {
    "iss": "https://provider.example",
    "iat": 12345678901,
    "exp": 12345679901,
    "cnf": {
      "jwk": {
        "kty": "OKP",
        "crv": "Ed25519",
        "x": "pUbLiCkEy123pUbLiCkEy123pUbLiCkEy123"
      }
    },
    "email": "demo@provider.example",
    "email_verified": true
  },
  "kbJwtDecodedHeader": {
    "alg": "EdDSA",
    "typ": "kb+jwt"
  },
  "kbJwtDecodedPayload": {
    "aud": "https://verifier.example",
    "iat": 12345678901,
    "nonce": "rAnDoM123rAnDoM123rAnDoM123rAnDoM123",
    "sd_hash": "hAsH456hAsH456hAsH456hAsH456hAsH456"
  },
  "disclosures": []
}

2. אימות של ערכים צפויים וטענות לגבי סשנים

בודקים שהערכים הבסיסיים במטען הייעודי (payload) תואמים לערכים שסיפקתם ולערכים הצפויים:

  • ‫email_verified: הערך צריך להיות true.
  • ‫email: כתובת האימייל צריכה להיות זהה לכתובת האימייל שצוינה בטופס.
  • ‫aud (קהל): חייב להיות זהה למקור של האתר.
  • ‫nonce: הערך צריך להיות זהה לערך ה-nonce שצוין בטופס.
  • ‫iat (הונפק בתאריך) ו-exp (תפוגה): מוודאים שהאסימון נמצא בחלון הזמן התקף שלו ושלא פג תוקפו.

3. אימות של העברת הרשאות ב-DNS

מאמתים את _email-verification רשומת ה-DNS של הדומיין של כתובת האימייל. לדוגמה, כדי לבדוק את demo@gmail.com, צריך לשלוח שאילתה לרשומת ה-TXT של _email-verification.gmail.com. עבור הספק הזה, השאילתה מחזירה את המיקום של ספק החשבון, כלומר accounts.google.com.

$ dig +short TXT _email-verification.gmail.com
"iss=accounts.google.com"

מוודאים שסכמת המנפיק היא https:// ושhttps://<domain> תואם להצהרת iss ב-EVT.

4. אימות החתימה של EVT

מאחזרים את מטא-נתוני הגילוי של המנפיק מכתובת https://<issuer>/.well-known/email-verification:

{
  "issuance_endpoint": "https://accounts.google.com/gsi/email-verification/issue",
  "jwks_uri": "https://verifiablecredentials-pa.googleapis.com/.well-known/vc-public-jwks",
  "signing_alg_values_supported": ["EdDSA"]
}

מאחזרים את קבוצת מפתחות האינטרנט של JSON מ-jwks_uri.

משתמשים בספריית SD-JWT כדי לאמת את חבילת הטוקן. הספרייה מתאמת את האימות:

  1. אימות חתימת המנפיק ב-EVT מול ה-JWKS שאוחזר.
  2. אימות החתימה של הדפדפן על ה-KB-JWT באמצעות המפתח הציבורי הזמני ב-cnf.jwk.
  3. אימות של הקישור למפתח (aud,‏ nonce והגיבוב (hash) של התקציר sd_hash).

דוגמה ללוגיקת אימות ב-Node.js:

import { SDJwtInstance } from "@sd-jwt/core";
import { importJWK, compactVerify } from "jose";
import { createHash } from "node:crypto";
const hasher = (data, alg) =>
  createHash(alg === "sha-256" ? "sha256" : alg)
    .update(data)
    .digest();
const sdJwt = new SDJwtInstance({ hasher });
sdJwt.config({
  hasher,
  // Verifier for the Issuer-signed EVT
  verifier: async (data, sig) => {
    const token = `${data}.${sig}`;
    const header = decoded.jwt.header;
    const headerAlg = header.alg || "ES256";
    // Match by kid if present, or iterate across matching algorithm keys
    const keysToTry = header.kid
      ? jwksData.keys.filter(k => k.kid === header.kid)
      : jwksData.keys;
    for (const jwk of keysToTry) {
      try {
        const pubKey = await importJWK(jwk, jwk.alg || headerAlg);
        await compactVerify(token, pubKey);
        return true;
      } catch {
        // Try next candidate key
      }
    }
    return false;
  },
  // Verifier for the Key Binding JWT (KB-JWT)
  kbVerifier: async (data, sig) => {
    try {
      const browserJwkKey = evtPayload.cnf?.jwk;
      if (!browserJwkKey) return false;
      const pubKey = await importJWK(browserJwkKey, decoded.kbJwt.header.alg || "ES256");
      await compactVerify(`${data}.${sig}`, pubKey);
      return true;
    } catch {
      return false;
    }
  },
});
// The library automatically verifies EVT signature, KB-JWT signature, audience, nonce, and sd_hash
const result = await sdJwt.verify(rawToken, {
  kb: {
    expectedNonce: sessionNonce,
    expectedAudience: "https://example.com",
    required: true,
  },
});
const verifiedPayload = result.payload;

אם כל השלבים יסתיימו בהצלחה, כתובת האימייל תאומת מול הספק. אם האימות נכשל, צריך לחזור לשליחת אימייל אישור למשתמש באמצעות התהליך הרגיל.

פתרון בעיות

אם האימות נכשל או שהדפדפן לא מספק טוקן, כדאי לבדוק את הבעיות הנפוצות הבאות:

שדה הטוקן ריק כששולחים את הטופס

  • הרשמה לגרסת מקור לניסיון: מוודאים שהכותרת Origin-Trial או התג <meta> מוצגים בדף. בגרסאות מקור לניסיון של צד שלישי (Chrome 154 ואילך), מקור הניסיון הרשום חייב להיות מאותו אתר כמו המנפיק (https://<issuer-domain>). אפשר לבדוק את ההגדרה של גרסת המקור לניסיון באתר בכלי הפיתוח בקטע Application (אפליקציה) > Frames (מסגרות) > (בוחרים את המסגרת הרלוונטית) > Origin trials (גרסאות מקור לניסיון).
  • תגי עיצוב של הטופס: התגים <input type="email" autocomplete="email"> ו-<input type="hidden" autocomplete="email-verification-token" nonce="..."> צריכים להיות באותו רכיב <form> (לא מבודדים בגבולות של Shadow DOM), והתג nonce לא יכול להיות ריק.
  • שליחה מוקדמת או שימוש חוזר בדף: הדפדפן מאחזר את האסימון ברקע אחרי שהמשתמש מזין את כתובת האימייל או אחרי שהיא מוזנת אוטומטית. אם שולחים את הבקשה לפני שהיא מסתיימת, האסימון נשאר ריק. זה יכול לקרות אם המשתמש לוחץ על מקש Return כדי לשלוח את הטופס אחרי שהוא מזין את כתובת האימייל שלו.
  • דרישות מוקדמות לגבי הדפדפן והספק: המשתמש צריך להיות מחובר לספק משתתף באותו פרופיל דפדפן, ולהפעיל את ההגדרה אימות כתובת אימייל בהגדרות Chrome (chrome://settings/contactInfo).

האימות של חתימת המוסד המנפיק נכשל

  • חסרה הכותרת kid: הטענה kid (מזהה מפתח) בכותרת EVT וב-JWKS היא אופציונלית (לדוגמה, Gmail משמיט את kid). אם kid לא מופיע, צריך לחזור על כל המפתחות האפשריים ב-jwks_uri של המנפיק במקום להיכשל בחיפוש מזהה מפתח.
  • מזהי אלגוריתמים (EdDSA ו-Ed25519): יכול להיות שגורמים מנפיקים וספריות יציינו את EdDSA או את Ed25519 (לצד ES256). חשוב לוודא שהלוגיקה של ייבוא ואימות JWK מקבלת את שני המזהים.
  • פורמט המקור של המנפיק (iss): רשומת ה-TXT של ה-DNS (_email-verification.<domain>) מכילה שם מארח ללא קידומת (iss=accounts.issuer.example), בעוד שהטענה של EVT iss היא מקור מלא של HTTPS (https://accounts.issuer.example, ללא לוכסן בסוף). הקידומת https:// לערך רשומת ה-DNS לפני ההשוואה.

האימות של קישור המפתח (KB-JWT) נכשל

  • ערך nonce לא תואם או שפג תוקפו: מוודאים שהערך nonce שמוצג ב-<input> תואם לערך nonce של הסשן הפעיל בשרת, ושלא בוצעה עליו החלפה על ידי כרטיסייה אחרת או שהוא לא נוצל על ידי בקשה קודמת.
  • אי התאמה של הקהל (aud): טענת aud היא מקור ה-HTTPS של מאמת הזהות (https://verifier.example, ללא נתיב או קו נטוי בסוף).

השוואה של הצהרת אימייל (email) נכשלת

  • שינוי אותיות רישיות וקנוניזציה: ב-Chrome מגרסה 156 ואילך, ההצהרה email מוחזרת בבייט אחר בייט כמו שהיא הוזנה בטופס, אבל בגרסאות קודמות של הדפדפן או אצל ספקים קודמים, יכול להיות שכתובת קנונית תוחזר (לדוגמה, First.Last@example.com במקום first.last@example.com). כשמשווים את ההצהרה email באסימון לערך בטופס שנשלח, צריך להשתמש בהשוואה לא תלוית-אותיות רישיות.