diff --git a/src/app.d.ts b/src/app.d.ts index 0c86dd3..c47d236 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -7,7 +7,10 @@ declare global { // Cloudflare Hyperdrive 바인딩 (wrangler 의 hyperdrive 설정으로 주입). // DB: DB_DIALECT=d1 일 때만 존재하는 Cloudflare D1 바인딩. D1 은 선택적 방언이므로 // 모든 wrangler.jsonc 가 선언하지는 않는다 — optional 로 두어 non-d1 배포에서도 타입이 성립한다. - env: Env & { SKIN_CACHE?: R2Bucket; HYPERDRIVE?: { connectionString: string }; DB?: D1Database }; + // EMAIL: Cloudflare Email Sending 바인딩(send_email). 설정 시 Workers 에서 + // nodemailer 대신 이 바인딩으로 트랜잭션 메일을 발송한다. wrangler.jsonc 의 + // send_email 로 주입되며, 없는 배포도 있으므로 optional. + env: Env & { SKIN_CACHE?: R2Bucket; HYPERDRIVE?: { connectionString: string }; DB?: D1Database; EMAIL?: SendEmail }; ctx: ExecutionContext; caches: CacheStorage; cf?: IncomingRequestCfProperties; diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts index 4fe15e6..e0007ad 100644 --- a/src/lib/server/email.ts +++ b/src/lib/server/email.ts @@ -1,6 +1,46 @@ -import nodemailer from "nodemailer"; import { env } from "$env/dynamic/private"; +// ── 발송 코어: 런타임 분기 ──────────────────────────────────────────────────── +// B6: nodemailer(raw TCP SMTP)는 Cloudflare Workers 런타임에서 동작하지 않는다 +// (net/tls 미지원). 따라서 발송 경로를 런타임별로 분기한다. +// - Cloudflare Workers: send_email 바인딩(platform.env.EMAIL)이 있으면 +// Cloudflare Email Sending 으로 발송한다. 이 바인딩은 sender 도메인만 +// 온보딩되어 있으면 "임의 외부 수신자"에게 트랜잭션 메일을 보낼 수 있다 +// (비밀번호 재설정/아이디 찾기에 적합). 수신자 검증이 필요한 것은 구 +// Email Routing 의 forward 이지, Email Sending 발송 경로가 아니다. +// - 그 외(Node/adapter-node): 기존 nodemailer(SMTP)로 발송한다. +// nodemailer 는 top-level import 를 제거하고 **동적 import** 로 바꿔 +// Workers 번들/런타임에 net/tls 의존이 끌려들어가지 않게 한다. + +type EnvLookup = Record; + +// send_email 바인딩(SendEmail)은 workerd 런타임이 주입하며 worker-configuration.d.ts +// 에 전역 타입으로 존재한다. platform.env 는 Env 확장이라 아직 EMAIL 이 선언돼 +// 있지 않을 수 있으므로 방어적으로 조회한다. +function getEmailBinding(platform: App.Platform | undefined): SendEmail | undefined { + const binding = (platform?.env as EnvLookup | undefined)?.EMAIL as SendEmail | undefined; + // send() 메서드가 존재해야 유효한 바인딩으로 간주. + return binding && typeof binding.send === "function" ? binding : undefined; +} + +function readEnv(platform: App.Platform | undefined, key: string): string | undefined { + const fromPlatform = (platform?.env as EnvLookup | undefined)?.[key]; + if (typeof fromPlatform === "string" && fromPlatform.length > 0) return fromPlatform; + // $env/dynamic/private 는 Workers 에서 platform.env, Node 에서 process.env 를 반영한다. + const fromEnv = (env as EnvLookup)?.[key]; + return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : undefined; +} + +// Cloudflare Email 발송에 필요한 sender 정보. from 주소는 반드시 Email Sending 에 +// 온보딩된 도메인이어야 한다(wrangler email sending enable ). 미설정이면 +// null 을 돌려 상위에서 "설정 없음"으로 처리한다. +function getCloudflareFrom(platform: App.Platform | undefined): { email: string; name: string } | null { + const email = readEnv(platform, "EMAIL_FROM"); + if (!email) return null; + const name = readEnv(platform, "EMAIL_FROM_NAME") ?? "KeyStone"; + return { email, name }; +} + function getSmtpConfig() { const hostname = env.SMTP_HOSTNAME; const port = env.SMTP_PORTNUMB; @@ -18,10 +58,12 @@ function getSmtpConfig() { }; } -async function send(to: string, subject: string, html: string): Promise { +async function sendViaNodemailer(to: string, subject: string, html: string, text: string): Promise { const smtp = getSmtpConfig(); - if (!smtp) throw new Error("SMTP 설정이 없습니다."); + if (!smtp) throw new Error("이메일 발송 설정이 없습니다. (SMTP_* 미설정)"); + // 동적 import: Workers 번들에 net/tls 의존을 top-level 로 끌어오지 않도록. + const { default: nodemailer } = await import("nodemailer"); const transporter = nodemailer.createTransport({ host: smtp.hostname, port: smtp.port, @@ -31,12 +73,34 @@ async function send(to: string, subject: string, html: string): Promise { transporter.setMaxListeners(20); try { - await transporter.sendMail({ from: smtp.senderAddress, to, subject, html }); + await transporter.sendMail({ from: smtp.senderAddress, to, subject, html, text }); } finally { transporter.close(); } } +// text 파트는 각 발송 함수가 명시적으로 작성해 넘긴다(HTML 을 정규식으로 역파싱하지 않는다 — +// 불완전 sanitization/이중 언이스케이프를 피하고, text/plain 렌더링 정확도를 높인다). +async function send(to: string, subject: string, html: string, text: string, platform: App.Platform | undefined): Promise { + // 1) Cloudflare Workers 경로 — send_email 바인딩이 있으면 최우선. + const emailBinding = getEmailBinding(platform); + if (emailBinding) { + const from = getCloudflareFrom(platform); + if (!from) throw new Error("이메일 발송 설정이 없습니다. (EMAIL_FROM 미설정 — Email Sending 온보딩 도메인 주소 필요)"); + await emailBinding.send({ + to, + from: { email: from.email, name: from.name }, + subject, + html, + text, + }); + return; + } + + // 2) Node(adapter-node) 경로 — nodemailer(SMTP). 동적 import. + await sendViaNodemailer(to, subject, html, text); +} + // ctrls H-MAIL-1: 이메일 본문 템플릿에 들어가는 동적 값들은 HTML/attribute // 컨텍스트 escape 가 필요하다. 정책 변경으로 username 정규식이 완화되거나 // resetUrl 의 형식 검증을 우회한 입력이 들어와도 HTML 인젝션이 발생하지 않도록 @@ -68,16 +132,17 @@ function baseHtml(title: string, body: string): string { `; } -export async function sendFindIdEmail(to: string, username: string): Promise { +export async function sendFindIdEmail(to: string, username: string, platform?: App.Platform): Promise { const html = baseHtml( "아이디 확인", `

요청하신 아이디 정보입니다.

${escapeHtml(username)}

`, ); - await send(to, "아이디 안내", html); + const text = `요청하신 아이디 정보입니다.\n\n${username}\n\n본인이 요청하지 않았다면 이 이메일을 무시해 주세요.`; + await send(to, "아이디 안내", html, text, platform); } -export async function sendPasswordResetEmail(to: string, resetUrl: string): Promise { +export async function sendPasswordResetEmail(to: string, resetUrl: string, platform?: App.Platform): Promise { const safeUrl = safeAbsoluteUrl(resetUrl); if (!safeUrl) { // 잘못된 URL 형식이면 메일 발송 자체 거부 — silent skip 으로 user enumeration 차단. @@ -91,7 +156,8 @@ export async function sendPasswordResetEmail(to: string, resetUrl: string): Prom 비밀번호 재설정

`, ); - await send(to, "비밀번호 재설정 안내", html); + const text = `아래 링크에서 비밀번호를 재설정하세요. 링크는 1시간 동안 유효합니다.\n\n${safeUrl}\n\n본인이 요청하지 않았다면 이 이메일을 무시해 주세요.`; + await send(to, "비밀번호 재설정 안내", html, text, platform); } export async function generateToken(): Promise<{ token: string; tokenHash: string }> { diff --git a/src/lib/server/saml/verify-xml-signature.ts b/src/lib/server/saml/verify-xml-signature.ts new file mode 100644 index 0000000..9f2f6ea --- /dev/null +++ b/src/lib/server/saml/verify-xml-signature.ts @@ -0,0 +1,120 @@ +/** + * Enveloped XML 서명(ds:Signature) 검증 — HTTP-POST 바인딩 AuthnRequest 용. + * + * HTTP-Redirect 바인딩은 서명이 URL 쿼리에 detached 로 실리지만(verifySamlRedirectSignature), + * HTTP-POST 바인딩의 서명 AuthnRequest 는 요청 XML 내부에 enveloped ds:Signature 로 실린다. + * 이 모듈은 xmldsigjs 로 그 서명을 검증한다. + * + * 신뢰 모델 (매우 중요): + * - 검증 키는 **오직 우리가 등록·신뢰하는 SP 인증서(certPem)의 공개키**만 사용한다. + * - XML 내부 의 인증서는 절대 신뢰하지 않는다. 공격자가 자기 키로 서명하고 + * 자기 인증서를 KeyInfo 에 심으면, KeyInfo 를 신뢰할 경우 위조가 통과되기 때문이다. + * - xmldsigjs 의 SignedXml.Verify(publicKey) 는 인자로 키를 넘기면 KeyInfo(GetPublicKeys)를 + * 쓰지 않고 그 키로만 검증한다(signed_xml.js: `const keys = key ? [key] : GetPublicKeys()`). + * + * 서명 래핑(XML Signature Wrapping, XSW) 방어: + * 1. 문서 내 는 정확히 1개여야 하며, 문서 루트(AuthnRequest)의 직계 자식이어야 한다 + * (enveloped 서명이 루트를 감싼다). + * 2. SignedInfo 의 Reference 는 정확히 1개여야 하며, 그 URI 는 문서 루트의 실제 ID(`#`)를 + * 가리켜야 한다. 다른 요소(주입된 wrapper)를 가리키는 서명은 거부한다. + * 3. 그 Reference 에 enveloped-signature transform 이 포함되어야 한다. + * 4. xmldsigjs 내부의 중복 ID 감지(findAllByIdExcludingSignatures)가 동일 ID 를 가진 두 요소를 + * 발견하면 예외를 던진다 — wrapper 로 동일 ID 를 복제하는 공격을 차단한다. + * 5. 서명/다이제스트 알고리즘은 SHA-256 이상만 허용한다(레거시 SHA-1 은 명시 옵트인 시만). + * + * ⚠ 이 검증은 우리(IdP)가 자체적으로 수행할 수 있는 암호 검증까지만 보장한다. 실제 SP 상호운용 + * (SP 가 실제로 어떤 c14n/transform/서명 배치를 쓰는지)은 별도의 interop 테스트가 필요하다. + */ + +import "reflect-metadata"; +import { X509Certificate } from "@peculiar/x509"; +import { env } from "$env/dynamic/private"; +import { ensureXmlEngine, xmldsigjs } from "./xml-setup"; + +const XMLDSIG_NS = "http://www.w3.org/2000/09/xmldsig#"; +const ENVELOPED_TRANSFORM = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"; + +// 허용 SignatureMethod (SHA-256 이상). SHA-1 은 IDP_ALLOW_SAML_SHA1=true 일 때만 예외 허용. +const ALLOWED_SIG_METHODS = new Set(["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"]); +const SHA1_SIG_METHOD = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"; + +// 허용 DigestMethod (SHA-256 이상). SHA-1 은 동일하게 옵트인 시만. +const ALLOWED_DIGEST_METHODS = new Set(["http://www.w3.org/2001/04/xmlenc#sha256", "http://www.w3.org/2001/04/xmldsig-more#sha384", "http://www.w3.org/2001/04/xmlenc#sha512"]); +const SHA1_DIGEST_METHOD = "http://www.w3.org/2000/09/xmldsig#sha1"; + +function sha1Allowed(): boolean { + return env.IDP_ALLOW_SAML_SHA1 === "true"; +} + +/** + * AuthnRequest XML 의 enveloped ds:Signature 를 SP 인증서 공개키로 검증한다. + * 검증 불가·형식 위반·서명 불일치 시 예외 없이 false 를 반환한다 (호출부는 false → 거부). + * + * @param xml AuthnRequest 원본 XML 문자열 (base64 디코드 후, deflate 없음) + * @param certPem 신뢰하는 SP 인증서(PEM). 이 인증서의 공개키로만 검증한다. + */ +export async function verifyEnvelopedXmlSignature(xml: string, certPem: string): Promise { + try { + if (!certPem) return false; + ensureXmlEngine(); + + // DOCTYPE/ENTITY 방어(호출 전 파서에서 이미 차단되지만 이중 방어). + if (/ t.Algorithm === ENVELOPED_TRANSFORM) : false; + if (!hasEnveloped) return false; + + // 서명 검증 — 반드시 신뢰하는 SP 공개키로만. 인자로 키를 넘기면 xmldsigjs 는 + // KeyInfo 를 무시하고 이 키로만 검증한다. 다이제스트 검증(ValidateReferences)에서 + // 중복 ID(XSW wrapper 복제)도 예외로 걸러진다. + return await signedXml.Verify(publicKey); + } catch { + // 파싱 실패·중복 ID 예외·다이제스트 불일치 등 모든 오류는 "검증 실패" = 거부. + return false; + } +} diff --git a/src/routes/(auth)/find-id/+page.server.ts b/src/routes/(auth)/find-id/+page.server.ts index 20c41cc..4ffe561 100644 --- a/src/routes/(auth)/find-id/+page.server.ts +++ b/src/routes/(auth)/find-id/+page.server.ts @@ -93,7 +93,7 @@ export const actions: Actions = { // 왕복을 돌면 응답 시간 차이로 존재 여부가 새어 나간다. 메일 발송을 응답 // 경로에서 분리해 (Workers: waitUntil, Node: fire-and-forget) 존재/비존재 // 응답 시간을 균일하게 맞춘다. 메일 발송은 best-effort 이므로 결과를 기다리지 않는다. - const sendPromise = sendFindIdEmail(email, user.username).catch(() => { + const sendPromise = sendFindIdEmail(email, user.username, event.platform).catch(() => { // 메일 발송 실패는 조용히 무시 }); const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx); diff --git a/src/routes/(auth)/find-password/+page.server.ts b/src/routes/(auth)/find-password/+page.server.ts index 5d9a519..760fd16 100644 --- a/src/routes/(auth)/find-password/+page.server.ts +++ b/src/routes/(auth)/find-password/+page.server.ts @@ -120,7 +120,7 @@ export const actions: Actions = { const resetUrl = `${issuer}/reset-password?${resetParams.toString()}`; // ctrls C5(후속): SMTP 왕복을 응답 경로에서 분리해 타이밍 계정 열거를 차단한다. // (find-id 와 동일 패턴 — Workers: waitUntil, Node: fire-and-forget.) - const sendPromise = sendPasswordResetEmail(user.email, resetUrl).catch(() => { + const sendPromise = sendPasswordResetEmail(user.email, resetUrl, event.platform).catch(() => { // 메일 발송 실패는 조용히 무시 }); const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx); diff --git a/src/routes/saml/sso/+server.ts b/src/routes/saml/sso/+server.ts index 9d90041..bfe9160 100644 --- a/src/routes/saml/sso/+server.ts +++ b/src/routes/saml/sso/+server.ts @@ -27,6 +27,7 @@ import { acrSatisfies } from "$lib/server/auth/constants"; import { samlAuthnRequestIds } from "$lib/server/db/schema"; import type { ParsedAuthnRequest } from "$lib/server/saml/parse-authn-request"; import { parseAuthnRequest, parseAuthnRequestPost, verifySamlRedirectSignature, encodeRedirectBindingSamlRequest } from "$lib/server/saml/parse-authn-request"; +import { verifyEnvelopedXmlSignature } from "$lib/server/saml/verify-xml-signature"; import { buildSignedSamlErrorResponse, buildSignedSamlResponse } from "$lib/server/saml/response"; import { findSp, recordSamlSession, type SamlSpRecord } from "$lib/server/saml/sp"; import { getUserMembership } from "$lib/server/org/membership"; @@ -547,7 +548,19 @@ export const POST: RequestHandler = async (event) => { throw error(400, "SAMLRequest 파라미터가 없습니다."); } - // HTTP-POST 바인딩: base64(XML), deflate 없음. + // HTTP-POST 바인딩: base64(XML), deflate 없음. 서명 검증·resume 재인코딩에 재사용하도록 + // XML 을 한 번만 디코드한다. 파서(parseAuthnRequestPost)와 서명 검증기가 동일한 원본 + // 문자열을 보게 해, 파싱 결과와 서명 대상이 분기되는 것을 막는다. + let xml: string; + try { + const raw = atob(samlRequestB64); + const bin = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bin[i] = raw.charCodeAt(i); + xml = new TextDecoder().decode(bin); + } catch { + throw error(400, "SAMLRequest 파싱 실패"); + } + let authnRequest: ParsedAuthnRequest; try { authnRequest = await parseAuthnRequestPost(samlRequestB64, relayState); @@ -564,12 +577,18 @@ export const POST: RequestHandler = async (event) => { // ── 서명 검증 (HTTP-POST 바인딩) ─────────────────────────────────────────── // POST 바인딩의 서명 AuthnRequest 는 URL 쿼리 서명이 아니라 요청 XML 내부의 enveloped - // XML 서명(ds:Signature)이다. 현재 코드베이스에는 enveloped XML 서명을 "검증"하는 - // 구현이 없다 (xmldsigjs 는 Response/Assertion 서명 "생성"에만 사용). 검증할 수 없는 - // 서명을 통과시키면 위조된 AuthnRequest 를 수용하게 되므로, 서명이 요구되거나 존재하면 - // 명시적으로 거부한다. (후속 PR: enveloped XML 서명 검증기 도입 후 이 분기 대체.) + // XML 서명(ds:Signature)이다. SP 가 서명을 요구(wantAuthnRequestsSigned)하거나 XML 에 + // 서명이 존재하면, 신뢰하는 SP 인증서(sp.cert) 공개키로만 enveloped 서명을 검증한다. + // (KeyInfo 의 인증서는 신뢰하지 않음 — verify-xml-signature.ts 참조.) if (sp.wantAuthnRequestsSigned || authnRequest.hasSignature) { - throw error(400, "POST 바인딩 서명 AuthnRequest 검증은 아직 지원되지 않습니다. (enveloped XML 서명 검증 미구현 — 후속)"); + if (!sp.cert) { + // 검증에 쓸 SP 인증서가 없으면 서명을 검증할 방법이 없다 → 거부. + throw error(400, "SP 인증서가 등록되지 않아 AuthnRequest 서명을 검증할 수 없습니다."); + } + const sigValid = await verifyEnvelopedXmlSignature(xml, sp.cert); + if (!sigValid) { + throw error(400, "AuthnRequest 서명 검증에 실패했습니다."); + } } const acsUrl = resolveAcsUrl(authnRequest, sp); @@ -579,12 +598,8 @@ export const POST: RequestHandler = async (event) => { throw error(503, "서명 키가 없습니다. 서버를 재시작하여 키를 생성하세요."); } - // 로그인/재인증 후 복귀 URL: POST body 는 GET 리다이렉트로 보존되지 않으므로, 동일 (미서명) + // 로그인/재인증 후 복귀 URL: POST body 는 GET 리다이렉트로 보존되지 않으므로, 동일 // AuthnRequest 를 HTTP-Redirect 바인딩으로 재인코딩해 기존 GET 경로가 그대로 재개하도록 한다. - const raw = atob(samlRequestB64); - const bin = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) bin[i] = raw.charCodeAt(i); - const xml = new TextDecoder().decode(bin); const resumeParams = new URLSearchParams(); resumeParams.set("SAMLRequest", await encodeRedirectBindingSamlRequest(xml)); if (relayState) resumeParams.set("RelayState", relayState); diff --git a/test/unit/saml-verify-xml-signature.test.ts b/test/unit/saml-verify-xml-signature.test.ts new file mode 100644 index 0000000..bf0faf1 --- /dev/null +++ b/test/unit/saml-verify-xml-signature.test.ts @@ -0,0 +1,164 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeAll } from "vitest"; +import * as x509 from "@peculiar/x509"; +import { ensureXmlEngine, xmldsigjs } from "$lib/server/saml/xml-setup"; +import { verifyEnvelopedXmlSignature } from "$lib/server/saml/verify-xml-signature"; + +const RSA_ALG: RsaHashedKeyGenParams = { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), +}; + +interface KeyCert { + keys: CryptoKeyPair; + certPem: string; + certB64: string; +} + +async function makeKeyCert(cn: string): Promise { + const keys = (await crypto.subtle.generateKey(RSA_ALG, true, ["sign", "verify"])) as CryptoKeyPair; + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: "01", + name: `CN=${cn}`, + notBefore: new Date("2020-01-01T00:00:00Z"), + notAfter: new Date("2035-01-01T00:00:00Z"), + signingAlgorithm: RSA_ALG, + keys, + }); + const certPem = cert.toString("pem"); + const certB64 = certPem + .replace(/-----BEGIN CERTIFICATE-----/, "") + .replace(/-----END CERTIFICATE-----/, "") + .replace(/\s+/g, ""); + return { keys, certPem, certB64 }; +} + +/** + * 테스트용 AuthnRequest 를 enveloped ds:Signature 로 서명한다 (response.ts 서명 생성과 동일 방식). + * Signature 는 바로 뒤(문서 루트의 직계 자식)에 삽입한다. + */ +async function signAuthnRequest(id: string, kc: KeyCert): Promise { + ensureXmlEngine(); + const issueInstant = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + const fullXml = + `` + + `https://sp.example.com` + + ``; + + const doc = xmldsigjs.Parse(fullXml); + const rootEl = doc.documentElement as Element & { setIdAttribute?: (name: string, flag: boolean) => void }; + rootEl.setIdAttribute?.("ID", true); + + const signedXml = new xmldsigjs.SignedXml(); + signedXml.XmlSignature.SignedInfo.CanonicalizationMethod.Algorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + await signedXml.Sign({ name: "RSASSA-PKCS1-v1_5" }, kc.keys.privateKey, doc, { + x509: [kc.certB64], + references: [{ uri: `#${id}`, hash: "SHA-256", transforms: ["enveloped", "exc-c14n"] }], + }); + + const sigNode = signedXml.XmlSignature.GetXml(); + if (sigNode) { + const issuerEls = rootEl.getElementsByTagNameNS("urn:oasis:names:tc:SAML:2.0:assertion", "Issuer"); + const issuerEl = issuerEls[0]; + if (issuerEl?.nextSibling) { + rootEl.insertBefore(sigNode, issuerEl.nextSibling); + } else { + rootEl.appendChild(sigNode); + } + } + return xmldsigjs.Stringify(doc).replace(/^<\?xml[^?]*\?>\s*/i, ""); +} + +beforeAll(() => { + x509.cryptoProvider.set(crypto as Crypto); +}); + +describe("verifyEnvelopedXmlSignature", () => { + it("SP 인증서 개인키로 서명한 AuthnRequest 는 통과한다", async () => { + const sp = await makeKeyCert("Test SP"); + const xml = await signAuthnRequest("_authnreq_ok", sp); + expect(await verifyEnvelopedXmlSignature(xml, sp.certPem)).toBe(true); + }); + + it("다른 키로 서명하면 실패한다 (KeyInfo 인증서가 아니라 신뢰 SP 인증서로만 검증)", async () => { + const signer = await makeKeyCert("Attacker SP"); + const trusted = await makeKeyCert("Trusted SP"); + // XML 은 attacker 키로 서명되고 KeyInfo 에도 attacker 인증서가 들어있지만, + // 검증은 trusted 인증서 공개키로만 한다 → 실패해야 한다. + const xml = await signAuthnRequest("_authnreq_wrongkey", signer); + expect(await verifyEnvelopedXmlSignature(xml, trusted.certPem)).toBe(false); + }); + + it("서명 후 본문(콘텐츠)을 변조하면 실패한다", async () => { + const sp = await makeKeyCert("Test SP"); + const xml = await signAuthnRequest("_authnreq_tamperbody", sp); + // Issuer 값을 변조 → 다이제스트 불일치. + const tampered = xml.replace("https://sp.example.com", "https://evil.example.com"); + expect(tampered).not.toBe(xml); + expect(await verifyEnvelopedXmlSignature(tampered, sp.certPem)).toBe(false); + }); + + it("SignatureValue 를 변조하면 실패한다", async () => { + const sp = await makeKeyCert("Test SP"); + const xml = await signAuthnRequest("_authnreq_tampersig", sp); + const m = xml.match(/<(?:\w+:)?SignatureValue[^>]*>([^<]+) { + const sp = await makeKeyCert("Test SP"); + const unsigned = + `https://sp.example.com`; + expect(await verifyEnvelopedXmlSignature(unsigned, sp.certPem)).toBe(false); + }); + + it("XSW: 서명된 요청을 다른 루트로 감싸면 실패한다 (참조 URI ≠ 문서 루트)", async () => { + const sp = await makeKeyCert("Test SP"); + const inner = await signAuthnRequest("_authnreq_inner", sp); + // inner(서명 포함)를 XML 선언 제거 후 악성 루트의 자식으로 감싼다. + const innerBody = inner.replace(/^<\?xml[^?]*\?>\s*/i, ""); + const wrapped = + `` + + `https://sp.example.com` + + `${innerBody}` + + ``; + // 악성 루트 ID(_evilroot)와 서명 참조 URI(#_authnreq_inner) 불일치 + Signature parent≠root → 거부. + expect(await verifyEnvelopedXmlSignature(wrapped, sp.certPem)).toBe(false); + }); + + it("XSW: 동일 ID 로 서명 요소를 복제하면 실패한다 (중복 ID)", async () => { + const sp = await makeKeyCert("Test SP"); + const inner = await signAuthnRequest("_dupid", sp); + const innerBody = inner.replace(/^<\?xml[^?]*\?>\s*/i, ""); + // 루트도 _dupid, 자식으로도 원본 서명요소(_dupid)를 심는다 → 중복 ID. + const wrapped = + `https://sp.example.com` + + `${innerBody}`; + expect(await verifyEnvelopedXmlSignature(wrapped, sp.certPem)).toBe(false); + }); + + it("빈 인증서(certPem)이면 실패한다", async () => { + const sp = await makeKeyCert("Test SP"); + const xml = await signAuthnRequest("_emptycert", sp); + expect(await verifyEnvelopedXmlSignature(xml, "")).toBe(false); + }); +}); diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 78c1b90..094469f 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -48,7 +48,28 @@ // OIDC/SAML 토큰 발급 issuer URL (배포 도메인과 일치시킬 것) "IDP_ISSUER_URL": "", + + // ── 이메일 발송 (비밀번호 재설정 / 아이디 찾기) ───────────────────────── + // Workers 배포에서는 nodemailer(raw SMTP)가 동작하지 않으므로 아래 + // Cloudflare Email Sending 바인딩(send_email)을 사용한다. + // EMAIL_FROM 은 반드시 Email Sending 에 온보딩된 도메인의 주소여야 한다. + // 온보딩: wrangler email sending enable + // (또는 대시보드 Email > Email Sending 에서 도메인 추가) + // 온보딩된 sender 도메인만 있으면 임의 외부 수신자에게 발송 가능하다 + // (수신자 사전 검증 불필요 — 이는 구 Email Routing forward 의 제약이었다). + "EMAIL_FROM": "noreply@yourdomain.com", + // 발신자 표시 이름 (선택, 기본값 "KeyStone") + "EMAIL_FROM_NAME": "KeyStone", }, + // ── Cloudflare Email Sending 바인딩 ─────────────────────────────────────── + // 코드가 platform.env.EMAIL 을 참조하므로 binding 이름은 반드시 EMAIL. + // from 도메인 온보딩 후 이 바인딩만 있으면 임의 수신자에게 발송된다. + // 로컬 개발(wrangler dev)에서 실제 발송을 테스트하려면 "remote": true 추가. + "send_email": [ + { + "name": "EMAIL", + }, + ], "r2_buckets": [ { // 커스텀 로그인 스킨 R2 캐시 버킷