Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 100 additions & 41 deletions src/lib/server/auth/password.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,127 @@
/**
* 패스워드 해싱 유틸리티
*
* 신규 해시: argon2id (@hicaru/argon2-pure.js — 순수 JS, Workers 호환)
* 레거시 해시: PBKDF2-SHA256 (검증 후 argon2id로 자동 업그레이드)
* 신규 해시: scrypt (node:crypto 네이티브 — Workers nodejs_compat / Node / Bun 공통)
* 레거시 해시: argon2id (@hicaru/argon2-pure.js), PBKDF2-SHA256 검증 후 scrypt 로 자동 업그레이드
*
* ctrls H-AUTH-4: OWASP profile 5 (m=7168 / t=5 / p=1) 로 상향.
* 기존 m=4096 / t=3 는 OWASP 최소 권고치 미달이라 DB 덤프 시 크래킹 비용이
* 부족했다. 순수 JS 구현 + Workers CPU 한도(paid 30s) 안에서 동작.
* 추후 WASM 기반 구현(@rabbit-company/argon2id) 또는 native binding 도입
* 시 더 높은 프로파일(owasp4: m=9216 / t=4, owasp3: m=12288 / t=3)로 상향
* 권장.
* argon2id → scrypt 전환 이유: @hicaru/argon2-pure.js 는 순수 JS 라 verify 1회에
* ~4.4초의 CPU 를 태운다 (Workers 요청 지연의 주범). Workers 는 런타임 WASM
* 바이트 컴파일을 금지해 WASM argon2 도입에는 빌드 배관이 필요한 반면,
* node:crypto scrypt 는 2025-04 부터 Workers 에서 네이티브(BoringSSL) 지원되어
* 동일한 메모리 하드 강도를 ~100ms 에 처리한다 (OWASP 승인 KDF).
*
* 파라미터: N=2^15, r=8, p=3 (32 MiB) — OWASP scrypt 최소 권고 조합 중 하나.
* Workers isolate 메모리 한도(128 MB) 안에서 동시 요청을 견디는 상한으로 선택.
*/

import { hashEncoded, verifyEncoded, Config, Variant, Version } from "@hicaru/argon2-pure.js";
import { randomBytes, scrypt as scryptCallback } from "node:crypto";
import { verifyEncoded } from "@hicaru/argon2-pure.js";

// ── scrypt ────────────────────────────────────────────────────────────────────

const SCRYPT_N = 32768; // 2^15
const SCRYPT_R = 8;
const SCRYPT_P = 3;
const SCRYPT_KEY_LENGTH = 32;
const SCRYPT_SALT_LENGTH = 16;
// BoringSSL 기본 maxmem(32 MiB)이 128*N*r = 32 MiB 와 경계라 여유를 둔다.
const SCRYPT_MAXMEM = 64 * 1024 * 1024;

// OWASP profile 5 — 순수 JS 구현으로 Workers 런타임 안에서 안정 동작 가능한 상한.
const ARGON2_CONFIG = new Config(
new Uint8Array(), // ad
32, // hashLength
1, // lanes (parallelism)
7168, // memCost (7 MB) — OWASP minimum
new Uint8Array(), // secret
5, // timeCost — OWASP minimum
Variant.Argon2id,
Version.Version13,
);
// 저장된 해시의 파라미터 파싱 시 허용 상한 — 손상/조작된 레코드가 요청당
// 과도한 메모리/CPU 를 유발하지 않도록 방어.
const SCRYPT_MAX_N = 131072; // 2^17
const SCRYPT_MAX_R = 32;
const SCRYPT_MAX_P = 16;

// ── argon2id ──────────────────────────────────────────────────────────────────
function deriveScrypt(password: string, salt: Uint8Array, keyLength: number, N: number, r: number, p: number): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
scryptCallback(password, salt, keyLength, { N, r, p, maxmem: SCRYPT_MAXMEM }, (err, derived) => {
if (err) reject(err);
else resolve(new Uint8Array(derived.buffer, derived.byteOffset, derived.byteLength));
});
});
}

export async function hashPassword(password: string): Promise<string> {
const pwd = new TextEncoder().encode(password);
const salt = crypto.getRandomValues(new Uint8Array(16));
return hashEncoded(pwd, salt, ARGON2_CONFIG);
const salt = new Uint8Array(randomBytes(SCRYPT_SALT_LENGTH));
const derived = await deriveScrypt(password, salt, SCRYPT_KEY_LENGTH, SCRYPT_N, SCRYPT_R, SCRYPT_P);
return `scrypt$N=${SCRYPT_N},r=${SCRYPT_R},p=${SCRYPT_P}$${bytesToBase64(salt)}$${bytesToBase64(derived)}`;
}

async function verifyScrypt(password: string, record: string): Promise<{ valid: boolean; paramsCurrent: boolean }> {
const invalid = { valid: false, paramsCurrent: true };
const parts = record.split("$");
if (parts.length !== 4) return invalid;
const [, params, saltB64, hashB64] = parts;

const parsed: Record<string, number> = {};
for (const pair of params.split(",")) {
const [key, value] = pair.split("=");
parsed[key] = Number(value);
}
const { N, r, p } = parsed;
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return invalid;
if (N < 2 || (N & (N - 1)) !== 0 || N > SCRYPT_MAX_N) return invalid;
if (r < 1 || r > SCRYPT_MAX_R || p < 1 || p > SCRYPT_MAX_P) return invalid;

try {
const salt = base64ToBytes(saltB64);
const storedHash = base64ToBytes(hashB64);
const derived = await deriveScrypt(password, salt, storedHash.length, N, r, p);
return {
valid: timingSafeEqual(derived, storedHash),
paramsCurrent: N === SCRYPT_N && r === SCRYPT_R && p === SCRYPT_P,
};
} catch {
return invalid;
}
}

// ── 공통 유틸 ─────────────────────────────────────────────────────────────────

function bytesToBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}

function base64ToBytes(value: string): Uint8Array {
return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
}

export function timingSafeEqual(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) return false;
let difference = 0;
for (let index = 0; index < left.length; index += 1) {
difference |= left[index] ^ right[index];
}
return difference === 0;
}

// ── 검증 진입점 ───────────────────────────────────────────────────────────────

export async function verifyPassword(
password: string,
record: string,
): Promise<{
valid: boolean;
rehash?: string;
}> {
const pwd = new TextEncoder().encode(password);
// scrypt (현행 형식) — 파라미터가 이후 상향된 경우 현행 파라미터로 자동 업그레이드
if (record.startsWith("scrypt$")) {
const { valid, paramsCurrent } = await verifyScrypt(password, record);
if (!valid) return { valid: false };
if (paramsCurrent) return { valid: true };
return { valid: true, rehash: await hashPassword(password) };
}

// argon2id PHC 형식 ($argon2id$...)
// argon2id 레거시 (@hicaru 순수 JS — verify 1회 ~4.4초) — 검증 후 scrypt 로 업그레이드
if (record.startsWith("$argon2")) {
const pwd = new TextEncoder().encode(password);
const valid = verifyEncoded(record, pwd);
return { valid };
if (!valid) return { valid: false };
return { valid: true, rehash: await hashPassword(password) };
}

// PBKDF2 레거시 형식 — 검증 후 argon2id로 업그레이드
// PBKDF2 레거시 — 검증 후 scrypt 로 업그레이드
if (record.startsWith("pbkdf2$")) {
const legacyResult = await verifyPbkdf2(password, record);
if (!legacyResult) return { valid: false };
Expand All @@ -63,19 +135,6 @@ export async function verifyPassword(

const PBKDF2_ITERATIONS = 100_000;

function base64ToBytes(value: string): Uint8Array {
return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
}

function timingSafeEqual(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) return false;
let difference = 0;
for (let index = 0; index < left.length; index += 1) {
difference |= left[index] ^ right[index];
}
return difference === 0;
}

async function verifyPbkdf2(password: string, record: string): Promise<boolean> {
const parts = record.split("$");
if (parts.length !== 4) return false;
Expand Down
37 changes: 32 additions & 5 deletions src/lib/server/oidc/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { and, eq } from "drizzle-orm";
import type { DB } from "$lib/server/db";
import { oidcClients } from "$lib/server/db/schema";
import { verifyPassword } from "$lib/server/auth/password";
import { timingSafeEqual, verifyPassword } from "$lib/server/auth/password";
import { b64uEncode } from "$lib/server/crypto/keys";

export type OidcClientRecord = typeof oidcClients.$inferSelect;

Expand Down Expand Up @@ -36,18 +37,44 @@ export function parseBasicAuth(authHeader: string): { clientId: string; clientSe
}
}

// client_secret 저장 해시. 비밀번호(저엔트로피)와 달리 client_secret 은 서버가
// 생성한 32바이트 랜덤값(256비트 엔트로피)이라 무차별대입이 불가능하므로,
// 메모리 하드 KDF 대신 SHA-256 단일 해시로 충분하다 (검증 <1ms — 순수 JS argon2 는
// verify 1회 ~4.4초라 토큰 엔드포인트 지연의 주범이었다). salt 없는 결정적 해시라
// timingSafeEqual 비교가 가능하다.
export async function hashClientSecret(clientSecret: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(clientSecret));
return `sha256$${b64uEncode(digest)}`;
}

export interface ClientSecretVerification {
valid: boolean;
/** 레거시 형식(argon2/pbkdf2) 검증 성공 시 sha256 형식으로 재저장할 값 */
rehash?: string;
}

// ctrls H-OIDC-3: public client (auth_method = "none") 의 Basic auth 거부.
// 기존엔 method === "none" 이면 secret 무관 true 반환 → public client 가 Basic 헤더로
// 빈 secret 보내도 통과. RFC 6749 §2.3.1: public client 는 인증 정보를 보내선 안 됨.
// 호출부에서 `hasAuthHeader` 를 넘기지 않으면 (기존 호출 호환) 기존 동작 유지하되,
// public client 의 secret 은 "" 인 경우만 통과시키도록 강화.
export async function isValidClientSecret(client: OidcClientRecord, clientSecret: string, hasAuthHeader: boolean = false): Promise<boolean> {
export async function isValidClientSecret(client: OidcClientRecord, clientSecret: string, hasAuthHeader: boolean = false): Promise<ClientSecretVerification> {
if (client.tokenEndpointAuthMethod === "none") {
return !hasAuthHeader && clientSecret === "";
return { valid: !hasAuthHeader && clientSecret === "" };
}
if (!client.clientSecretHash || !clientSecret) return false;
if (!client.clientSecretHash || !clientSecret) return { valid: false };

// sha256 (현행 형식)
if (client.clientSecretHash.startsWith("sha256$")) {
const expected = new TextEncoder().encode(await hashClientSecret(clientSecret));
const stored = new TextEncoder().encode(client.clientSecretHash);
return { valid: timingSafeEqual(expected, stored) };
}

// argon2/pbkdf2 레거시 — 검증 성공 시 sha256 으로 업그레이드
const result = await verifyPassword(clientSecret, client.clientSecretHash);
return result.valid;
if (!result.valid) return { valid: false };
return { valid: true, rehash: await hashClientSecret(clientSecret) };
}

export function parseRedirectUris(client: OidcClientRecord): string[] {
Expand Down
6 changes: 3 additions & 3 deletions src/routes/admin/oidc-clients/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Actions, PageServerLoad } from "./$types";
import { requireAdminContext } from "$lib/server/auth/guards";
import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index";
import { oidcClients } from "$lib/server/db/schema";
import { hashPassword } from "$lib/server/auth/password";
import { hashClientSecret } from "$lib/server/oidc/client";

function generateClientId(): string {
return crypto.randomUUID().replace(/-/g, "").slice(0, 20);
Expand Down Expand Up @@ -179,7 +179,7 @@ export const actions: Actions = {

const clientId = generateClientId();
const clientSecret = tokenMethod !== "none" ? generateClientSecret() : null;
const clientSecretHashed = clientSecret ? await hashPassword(clientSecret) : null;
const clientSecretHashed = clientSecret ? await hashClientSecret(clientSecret) : null;

await db.insert(oidcClients).values({
id: crypto.randomUUID(),
Expand Down Expand Up @@ -298,7 +298,7 @@ export const actions: Actions = {
if (!id) return fail(400, { error: "잘못된 요청입니다." });

const newSecret = generateClientSecret();
const newSecretHashed = await hashPassword(newSecret);
const newSecretHashed = await hashClientSecret(newSecret);
await db
.update(oidcClients)
.set({ clientSecretHash: newSecretHashed, updatedAt: new Date() })
Expand Down
37 changes: 27 additions & 10 deletions src/routes/oidc/token/+server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { json } from "@sveltejs/kit";
import { eq } from "drizzle-orm";
import type { RequestHandler } from "./$types";
import { oidcClients } from "$lib/server/db/schema";
import { requireDbContext } from "$lib/server/auth/guards";
import { findActiveUserById } from "$lib/server/auth/users";
import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit";
Expand Down Expand Up @@ -110,10 +112,21 @@ export const POST: RequestHandler = async (event) => {
return tokenError("invalid_client", "등록되지 않은 클라이언트입니다.", 401);
}

if (!(await isValidClientSecret(client, clientSecret, !!authHeader))) {
const secretCheck = await isValidClientSecret(client, clientSecret, !!authHeader);
if (!secretCheck.valid) {
await recordTokenFailure(clientId, "invalid_client", "client_secret 검증 실패");
return tokenError("invalid_client", "클라이언트 인증에 실패했습니다.", 401);
}
// 레거시 형식(argon2/pbkdf2) 해시는 검증 성공 시 sha256 으로 업그레이드 (best-effort)
if (secretCheck.rehash) {
try {
await db.update(oidcClients).set({ clientSecretHash: secretCheck.rehash, updatedAt: new Date() }).where(eq(oidcClients.id, client.id));
} catch (error) {
// 실패해도 토큰 발급은 계속하되, 반복 실패(스키마/권한 문제)가 보이도록 로깅.
// 업그레이드 전까지 이 클라이언트는 매 요청 레거시 KDF(느린 경로)를 탄다.
console.error("client_secret 해시 업그레이드 실패", { clientId }, error);
}
}

const code = String(body.get("code") ?? "");
const redirectUri = String(body.get("redirect_uri") ?? "");
Expand Down Expand Up @@ -149,13 +162,24 @@ export const POST: RequestHandler = async (event) => {
}
}

const user = await findActiveUserById(db, grant.userId);
// 서로 독립인 조회 3개를 병렬 실행 (Workers 요청당 PG 연결은 max 5 라 여유 있음).
// 서비스 권한 매핑은 grant 발급 후 revoke 됐을 수 있으므로 token 시점에서 다시 조회한다.
const [user, signingKey, assignment] = await Promise.all([
findActiveUserById(db, grant.userId),
getActiveSigningKey(db, tenant.id, signingKeySecret),
getActiveAssignment(db, {
tenantId: tenant.id,
userId: grant.userId,
serviceType: "oidc",
serviceRefId: client.id,
}),
]);

if (!user) {
await recordTokenFailure(clientId, "invalid_grant", "사용자 조회 실패");
return tokenError("invalid_grant", "사용자를 찾을 수 없습니다.");
}

const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret);
if (!signingKey) {
return tokenError("server_error", "활성 서명 키를 찾을 수 없습니다.", 503);
}
Expand Down Expand Up @@ -204,13 +228,6 @@ export const POST: RequestHandler = async (event) => {
if (grant.acr) idTokenPayload.acr = grant.acr;

// 서비스 권한 매핑 — role / 추가 attributes 를 ID Token 에 머지한다.
// grant 발급 후 매핑이 revoke 됐을 수 있으므로 token 시점에서 다시 조회한다.
const assignment = await getActiveAssignment(db, {
tenantId: tenant.id,
userId: user.id,
serviceType: "oidc",
serviceRefId: client.id,
});
if (assignment?.role) {
idTokenPayload.roles = [assignment.role.key];
idTokenPayload.roles_label = assignment.role.label;
Expand Down
Loading