신뢰 당사자 구현

사이트에서 이메일 인증을 구현하려면 토큰을 요청하도록 양식 마크업을 업데이트하고 수신 토큰에 서버 측 유효성 검사를 추가하세요.

오리진 트라이얼 등록

사이트를 확인하려면 사이트에 오리진 트라이얼이 구성되어 있어야 합니다.

Chrome 154부터 서드 파티 출처 트라이얼이 지원되지만 중요한 주의사항이 있습니다. 트라이얼에 등록된 출처는 발급자와 동일한 사이트여야 합니다. 예를 들면 다음과 같습니다.

  • 발급기관 도메인: issuer.example
  • OT 등록자: 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">

필드 요구사항:

  • 이메일 필드: Chrome에서 주소를 자동 완성하고 인식할 수 있도록 type="email" 및 autocomplete="email"를 설정합니다.
  • 토큰 필드 속성:
    • 설정 autocomplete="email-verification-token": Chrome은 이 필드를 식별하여 제출 시 토큰을 채웁니다.
    • nonce="<VALUE>" 설정: 사이트에서 양식 제출을 확인하기 위해 세션에 바인딩된 고유한 nonce를 제공해야 합니다.

이메일 인증 토큰 (EVT) 검증

사용자가 양식을 제출하면 서버는 숨겨진 필드에서 이메일 주소와 토큰을 수신합니다. 토큰 필드가 비어 있으면 브라우저나 제공업체가 EVP를 지원하지 않거나 사용자가 인증을 건너뛴 것입니다. 이 경우 OTP 또는 매직 링크 전송과 같은 기존 인증 절차로 대체하세요.

토큰이 있으면 다음과 같이 토큰을 검증합니다.

  1. SD-JWT 라이브러리를 사용하여 토큰을 파싱합니다.
  2. 예상 값과 세션 클레임을 검증합니다.
  3. DNS 위임을 확인합니다.
  4. 발급자 메타데이터를 검색하고 JWKS를 가져옵니다.
  5. 암호화 서명 및 키 바인딩을 확인합니다.

1. 토큰 파싱

토큰은 RFC 9901: 선택적 공개 JWT(SD-JWT+KB) 형식을 사용합니다. 플랫폼에 적합한 라이브러리를 사용하여 토큰을 파싱하고 검증합니다. 예를 들어 Node의 경우 @sd-jwt/core 및 jose를 사용할 수 있습니다. 원시 형식은 다음과 같습니다. 발급기관 서명 JWT, 그 뒤에 0개 이상의 공개가 오고, 각 구성요소가 물결표로 구분된 키 바인딩 JWT로 끝납니다.

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

현재 구현에서 토큰에는 공개 정보가 0개(<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. 예상 값 및 세션 클레임 검증

페이로드의 기본값이 제공되고 예상되는 값과 일치하는지 확인합니다.

  • email_verified: true이어야 합니다.
  • email: 양식에 제출된 이메일 주소와 일치해야 합니다.
  • aud (잠재고객): 사이트의 출처와 일치해야 합니다.
  • nonce: 양식에 제공된 nonce와 일치해야 합니다.
  • iat (발급 시간) 및 exp (만료): 토큰이 유효한 시간 범위 내에 있고 만료되지 않았는지 확인합니다.

3. DNS 위임 확인

이메일 주소 도메인의 _email-verification DNS 레코드를 확인합니다. 예를 들어 demo@gmail.com의 경우 _email-verification.gmail.com TXT 레코드를 쿼리합니다. 이 제공자의 경우 쿼리는 계정 제공자의 위치(accounts.google.com)를 반환합니다.

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

발급기관 스키마가 https://이고 https://<domain>이 EVT의 iss 클레임과 일치하는지 확인합니다.

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"]
}

jwks_uri에서 JSON 웹 키 세트를 가져옵니다.

SD-JWT 라이브러리를 사용하여 토큰 패키지를 확인합니다. 라이브러리는 다음을 통해 유효성 검사를 조정합니다.

  1. 가져온 JWKS에 대해 EVT의 발급기관 서명을 검증합니다.
  2. cnf.jwk의 임시 공개 키를 사용하여 KB-JWT에 대한 브라우저의 서명을 검증합니다.
  3. 키 바인딩 (aud, nonce, 다이제스트 해시 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>)와 동일한 사이트여야 합니다. DevTools의 Application > Frames > (select the relevant frame) > Origin trials에서 사이트의 오리진 트라이얼 구성을 검사할 수 있습니다.
  • 양식 마크업: <input type="email" autocomplete="email">와 <input type="hidden" autocomplete="email-verification-token" nonce="...">는 모두 동일한 <form> 요소에 있어야 하며 (섀도우 DOM 경계에서 격리되지 않음) nonce는 비어 있으면 안 됩니다.
  • 조기 제출 또는 재사용된 페이지: 이메일이 입력되거나 자동 완성된 후 브라우저가 백그라운드에서 토큰을 가져옵니다. 요청이 완료되기 전에 제출하면 토큰이 비어 있습니다. 사용자가 이메일을 입력한 후 Return 키를 눌러 양식을 제출하면 이 문제가 발생할 수 있습니다.
  • 브라우저 및 제공업체 필수사항: 사용자가 동일한 브라우저 프로필에서 참여 제공업체에 로그인되어 있어야 하며 Chrome 설정 (chrome://settings/contactInfo)에서 인증된 이메일이 사용 설정되어 있어야 합니다.

발급기관 서명 확인 실패

  • kid 헤더 누락: EVT 헤더 및 JWKS의 kid (키 ID) 클레임은 선택사항입니다 (예: Gmail은 kid을 생략함). kid이 없는 경우 키 ID 조회를 실패하는 대신 발급자의 jwks_uri에 있는 모든 후보 키를 반복합니다.
  • 알고리즘 식별자 (EdDSA 및 Ed25519): 발급자와 라이브러리는 EdDSA 또는 Ed25519 (ES256과 함께)를 지정할 수 있습니다. JWK 가져오기 및 확인 로직이 두 식별자를 모두 허용하는지 확인하세요.
  • 발급자 (iss) 출처 형식: DNS TXT 레코드(_email-verification.<domain>)에는 베어 호스트 이름(iss=accounts.issuer.example)이 포함되어 있지만 EVT iss 클레임은 후행 슬래시가 없는 전체 HTTPS 출처 (https://accounts.issuer.example)입니다. 비교하기 전에 DNS 레코드 값에 https://를 접두사로 추가합니다.

키 바인딩 (KB-JWT) 검증 실패

  • nonce가 일치하지 않거나 만료됨: <input>에 렌더링된 nonce가 서버의 활성 세션 nonce와 일치하고 다른 탭에 의해 덮어쓰이지 않았으며 이전 요청에 의해 사용되지 않았는지 확인합니다.
  • 잠재고객 (aud) 불일치: aud 클레임은 인증 기관의 HTTPS 출처 (https://verifier.example, 경로 또는 후행 슬래시 없음)입니다.

이메일 클레임 (email) 비교 실패

  • 대소문자 및 정규화: Chrome 156 이상에서는 email 클레임을 양식에 입력된 대로 바이트 단위로 반환하지만 이전 브라우저 버전이나 제공업체에서는 정규화된 주소 (예: first.last@example.com의 경우 First.Last@example.com)를 반환할 수 있습니다. 토큰의 email 클레인을 제출된 양식 값과 일치시킬 때는 대소문자를 구분하지 않는 비교를 사용하세요.