JWK 관리 서비스 구현 (JwkManagementService)
이 문서는 JWK(JSON Web Key) 관리 및 관련 암호화 연산을 담당하는 JwkManagementService의 구현에 대한 세부 사항을 설명합니다. 이 서비스는 AuthService 및 AppTokenService 등 JWT 서명 및 검증이 필요한 다른 서비스들에게 공통 기능을 제공합니다.
목차
개요
JwkManagementService는 JWK 기반의 비대칭 키 암호화(RS256)를 위한 핵심 기능을 중앙에서 관리하고 제공합니다. 이를 통해 JWK 키 관리 로직의 중복을 제거하고, 인증 관련 서비스들의 책임을 명확히 분리하며, 유지보수성을 향상시키는 것을 목표로 합니다.
서비스 책임
- 데이터베이스에서 활성 JWK 목록 조회
- JWK 데이터의 유효성 검증
- 데이터베이스 레코드를 내부 JWK 객체 형태로 변환
- 서명에 사용할 최신 JWK 제공
- 검증에 사용할 특정 JWK (kid 기반) 제공
- JWK Private Key를 이용한 JWT 페이로드 서명 (RS256)
- JWK Public Key를 이용한 JWT 토큰 검증 (RS256)
JwkManagementService 구현
// 파일 경로: libs/feature/auth/src/lib/domain/services/jwk-management.service.ts
import { Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '@dta-wide-mono/prisma'; // PrismaService 경로 확인 필요
import { ConfigService } from '@core/config'; // ConfigService 경로 확인 필요
import { sign, verify, decode, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
import { parse, LosslessNumber } from 'lossless-json'; // BigInt 처리를 위해 사용
// 필요한 타입 정의 (별도 파일 또는 여기에 정의)
interface JwkPublicKey {
kty: 'RSA';
n: string;
e: string;
kid: string;
use: 'sig';
// 기타 필요한 필드
}
interface JwkPrivateKeyPrecomputed {
Dp: LosslessNumber;
Dq: LosslessNumber;
Qinv: LosslessNumber;
// CRTValues: any[]; // 필요시 포함
}
interface JwkPrivateKey {
D: LosslessNumber;
E: LosslessNumber;
N: LosslessNumber;
Primes: LosslessNumber[];
Precomputed: JwkPrivateKeyPrecomputed;
// 기타 필요한 필드
}
interface JWK {
id: number;
jwk: JwkPublicKey;
private_key: JwkPrivateKey;
created_at: Date;
}
// DB 조회 결과 타입 (Prisma $queryRaw 반환 타입에 맞춰 조정 필요)
interface JwkDbRecord {
id: number;
jwk: string; // JSON 문자열
private_key: string; // JSON 문자열
created_at: Date;
}
@Injectable()
export class JwkManagementService {
private readonly logger = new Logger(JwkManagementService.name);
private jwksCache: JWK[] | null = null;
private cacheExpiry: number | null = null;
// 설정에서 캐시 TTL 가져오거나 기본값 사용
private readonly cacheTTL: number = this.configService.get<number>('JWK_CACHE_TTL_MS', 5 * 60 * 1000);
constructor(
private prisma: PrismaService,
private configService: ConfigService
) {}
/**
* 데이터베이스 또는 캐시에서 JWK 목록을 가져옵니다.
*/
async getJWKs(): Promise<JWK[]> {
const now = Date.now();
// 캐시가 유효하면 캐시된 데이터 반환
if (this.jwksCache && this.cacheExpiry && now < this.cacheExpiry) {
this.logger.debug('Returning cached JWKs');
return this.jwksCache;
}
// 캐시가 없거나 만료되었으면 DB에서 새로 조회
this.logger.log('Fetching JWKs from database...');
const jwksFromDb = await this.fetchJWKsFromDb();
// 조회된 JWK 캐싱
this.jwksCache = jwksFromDb;
this.cacheExpiry = now + this.cacheTTL;
return jwksFromDb;
}
/**
* RAW 쿼리를 사용하여 데이터베이스에서 JWK 목록을 직접 가져옵니다.
*/
private async fetchJWKsFromDb(): Promise<JWK[]> {
try {
// auth.jwk 테이블에서 필요한 컬럼 조회 (active 상태 등 필터링 조건 추가 가능)
const jwkRecords = await this.prisma.$queryRaw<JwkDbRecord[]>`
SELECT id, jwk::text as jwk, private_key::text as private_key, created_at
FROM auth.jwk
WHERE status = 'ACTIVE' -- 예시: 활성 상태인 키만 조회
ORDER BY created_at DESC`;
if (!jwkRecords || jwkRecords.length === 0) {
this.logger.error('No active JWKs found in the database.');
throw new InternalServerErrorException('No active JWKs available');
}
// 각 레코드를 JWK 객체로 변환 (유효성 검증 포함)
return jwkRecords.map(record => this.transformToJWK(record))
.filter(jwk => this.validateJwk(jwk)); // 변환 후 유효한 것만 필터링
} catch (error) {
this.logger.error('Failed to fetch JWKs from database', error);
throw new InternalServerErrorException('Failed to fetch JWKs from database');
}
}
/**
* 데이터베이스 레코드를 JWK 객체로 변환합니다.
*/
private transformToJWK(record: JwkDbRecord): JWK {
try {
const jwkPublicKey = JSON.parse(record.jwk) as JwkPublicKey;
// private_key는 JSON 문자열, lossless-json으로 파싱
const jwkPrivateKey = parse(record.private_key) as JwkPrivateKey | null;
if (!jwkPrivateKey) {
throw new Error('Failed to parse private key JSON.');
}
// 필요한 기본 검증 (더 상세한 검증은 validateJwk에서)
if (typeof jwkPublicKey !== 'object' || jwkPublicKey === null ||
typeof jwkPrivateKey !== 'object' || jwkPrivateKey === null) {
throw new Error('Invalid JWK or Private Key structure after parsing.');
}
return {
id: record.id,
jwk: jwkPublicKey,
private_key: jwkPrivateKey,
created_at: record.created_at,
};
} catch (err) {
this.logger.error(`Failed to transform DB record to JWK (ID: ${record.id}):`, err);
// 변환 실패 시 예외를 던져 filter 단계에서 걸러지도록 함
throw new InternalServerErrorException(`Invalid JWK data format for ID: ${record.id}`);
}
}
/**
* JWK 객체의 유효성을 검증합니다.
*/
private validateJwk(jwk: JWK | null): jwk is JWK {
if (!jwk || !jwk.jwk || !jwk.private_key) {
this.logger.warn('Invalid JWK structure passed to validation', { jwkId: jwk?.id });
return false;
}
const publicKey = jwk.jwk;
const privateKey = jwk.private_key;
// 기본 필드 존재 및 타입 검증
if (publicKey.kty !== 'RSA' || !publicKey.n || !publicKey.e || !publicKey.kid || publicKey.use !== 'sig') {
this.logger.warn('Invalid or missing RSA public key parameters', { kid: publicKey.kid });
return false;
}
// Private key 필수 필드 존재 검증 (LosslessNumber 타입 고려)
if (!privateKey.D || !privateKey.N || !privateKey.E || !privateKey.Primes || !Array.isArray(privateKey.Primes) || privateKey.Primes.length < 2 || !privateKey.Precomputed || !privateKey.Precomputed.Dp || !privateKey.Precomputed.Dq || !privateKey.Precomputed.Qinv) {
this.logger.warn('Invalid or missing RSA private key parameters', { kid: publicKey.kid });
return false;
}
// 추가적인 값 검증 (예: N 값 비교 등) 필요시 구현
return true;
}
/**
* 서명에 사용할 최신 JWK를 가져옵니다.
*/
async getLatestJwkForSigning(): Promise<JWK> {
const jwks = await this.getJWKs();
if (jwks.length === 0) {
// fetchJWKsFromDb에서 이미 예외 처리되었어야 함
throw new InternalServerErrorException('No valid JWKs available for signing.');
}
// getJWKs가 created_at 기준으로 정렬된 결과를 반환한다고 가정 (DB 쿼리 확인)
// 또는 여기서 다시 정렬
const latestJwk = jwks.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())[0];
return latestJwk;
}
/**
* 검증에 사용할 JWK를 kid로 찾습니다.
*/
async findJwkForVerification(kid: string): Promise<JWK | undefined> {
const jwks = await this.getJWKs();
return jwks.find(key => key.jwk.kid === kid);
}
/**
* JWK Private Key를 사용하여 페이로드를 서명합니다 (RS256).
*/
async signWithJwk(payload: Record<string, any>, jwk: JWK): Promise<string> {
if (!this.validateJwk(jwk)) {
throw new InternalServerErrorException('Attempted to sign with an invalid JWK.');
}
try {
// LosslessNumber를 16진수 문자열로 변환하는 헬퍼 함수 (레거시 방식)
const toHexString = (num: LosslessNumber): string => BigInt(num.toString()).toString(16);
// PEM 생성을 위한 Private Key 컴포넌트 구성 (레거시 hex -> base64url 방식 적용)
const privateKeyComponents = {
kty: 'RSA',
n: jwk.jwk.n, // Public Modulus
e: jwk.jwk.e, // Public Exponent
// Private 컴포넌트: 16진수 문자열 -> hex Buffer -> base64url 문자열
d: Buffer.from(toHexString(jwk.private_key.D), 'hex').toString('base64url'), // Private Exponent
p: Buffer.from(toHexString(jwk.private_key.Primes[0]), 'hex').toString('base64url'), // Prime 1
q: Buffer.from(toHexString(jwk.private_key.Primes[1]), 'hex').toString('base64url'), // Prime 2
dp: Buffer.from(toHexString(jwk.private_key.Precomputed.Dp), 'hex').toString('base64url'), // Exponent1
dq: Buffer.from(toHexString(jwk.private_key.Precomputed.Dq), 'hex').toString('base64url'), // Exponent2
qi: Buffer.from(toHexString(jwk.private_key.Precomputed.Qinv), 'hex').toString('base64url') // Coefficient
};
// jwk-to-pem을 사용하여 PEM 형식 Private Key 생성
const privateKeyPEM = jwkToPem(privateKeyComponents as any, { private: true });
// jsonwebtoken.sign으로 서명
const token = sign(payload, privateKeyPEM, {
algorithm: 'RS256',
header: {
alg: 'RS256',
kid: jwk.jwk.kid, // 헤더에 Key ID 포함
typ: 'JWT'
}
});
return token;
} catch (error) {
this.logger.error(`JWT signing failed for kid: ${jwk.jwk.kid}`, error);
// Private Key 컴포넌트 값 로깅은 보안상 주의 필요
// console.error('Error during PEM conversion or signing:', error);
throw new InternalServerErrorException('Failed to sign JWT using JWK');
}
}
/**
* JWK Public Key를 사용하여 토큰을 검증합니다 (RS256).
* @throws {JsonWebTokenError | TokenExpiredError} 검증 실패 시
*/
async verifyWithJwk(token: string, jwk: JWK): Promise<Record<string, any>> {
if (!this.validateJwk(jwk)) { // 내부 JWK 객체 유효성 재확인
this.logger.error('Invalid JWK provided for verification', { kid: jwk?.jwk?.kid });
throw new InternalServerErrorException('Invalid JWK for verification.');
}
try {
// PEM 생성을 위한 Public Key 컴포넌트
const publicKeyComponents = {
kty: 'RSA',
n: jwk.jwk.n,
e: jwk.jwk.e,
// kid, use 등은 jwk-to-pem에서 필수 아님
};
// jwk-to-pem을 사용하여 PEM 형식 Public Key 생성
const publicKeyPEM = jwkToPem(publicKeyComponents as any);
// jsonwebtoken.verify로 검증
// verify 함수는 검증 실패 시 자체적으로 에러 throw (TokenExpiredError, JsonWebTokenError 등)
const decodedPayload = verify(token, publicKeyPEM, {
algorithms: ['RS256'] // RS256 알고리즘 명시
});
// 타입 가드 (verify 반환 타입이 object인지 확인)
if (typeof decodedPayload !== 'object' || decodedPayload === null) {
throw new JsonWebTokenError('Invalid payload type after verification');
}
// 검증 성공 시 페이로드 반환
return decodedPayload as Record<string, any>;
} catch (error) {
// jsonwebtoken의 검증 오류(TokenExpiredError, JsonWebTokenError 등) 또는 PEM 변환 오류
this.logger.warn(`JWT verification failed for kid: ${jwk.jwk.kid}`, error.message);
// 원본 오류를 그대로 throw하여 호출 측에서 처리하도록 함
throw error;
}
}
}
주요 메서드 상세
JWK 조회 및 관리
getJWKs(): 캐시된 JWK가 유효하면 반환하고, 아니면fetchJWKsFromDb()를 호출하여 DB에서 가져온 후 캐싱합니다.fetchJWKsFromDb(): Prisma$queryRaw를 사용하여auth.jwk테이블에서 JWK 및 Private Key 데이터를 조회합니다. 보안을 위해 필요한 컬럼만 선택합니다.transformToJWK(): DB에서 조회한 raw text 데이터를lossless-json으로 파싱하여JwkPublicKey및JwkPrivateKey구조로 변환합니다. BigInt 처리를 위해LosslessNumber를 사용합니다.validateJwk(): 변환된 JWK 객체의 필수 필드(kty, n, e, kid 등) 존재 및 형식을 검증합니다.getLatestJwkForSigning():getJWKs()를 호출하여 얻은 목록에서created_at이 가장 최신인 유효한 JWK를 반환합니다.findJwkForVerification():getJWKs()를 호출하여 얻은 목록에서 주어진kid와 일치하는 JWK를 찾아 반환합니다.
JWT 서명 (RS256)
signWithJwk():- 입력받은
jwk의private_key정보를lossless-json의LosslessNumber에서 16진수 문자열로 변환합니다. - 변환된 값들을 사용하여
jwk-to-pem라이브러리가 요구하는 형식의 private key component 객체를 생성합니다. jwkToPem(privateKeyComponents, { private: true })를 호출하여 PEM 형식의 private key를 생성합니다.jsonwebtoken.sign()함수를 호출하여 페이로드를 PEM 키로 서명합니다.algorithm: 'RS256', 헤더(kid포함)를 명시적으로 지정합니다.- 서명된 JWT 문자열을 반환합니다.
- 입력받은
JWT 검증 (RS256)
verifyWithJwk():- 입력받은
jwk의jwk(public key) 정보를 사용하여jwk-to-pem라이브러리가 요구하는 형식의 public key component 객체를 생성합니다. jwkToPem(publicKeyComponents)를 호출하여 PEM 형식의 public key를 생성합니다.jsonwebtoken.verify()함수를 호출하여 토큰을 PEM 키로 검증합니다.algorithms: ['RS256']을 지정합니다.- 검증 성공 시 디코딩된 페이로드를 반환합니다.
- 검증 실패 시(
TokenExpiredError,JsonWebTokenError등) 적절한 예외를 발생시키거나 re-throw 합니다.
- 입력받은
타입 정의
JWK 관련 타입(JwkPublicKey, JwkPrivateKey, JWK 등)은 서비스 구현 또는 별도의 타입 정의 파일(*.types.ts)에 위치할 수 있습니다. lossless-json의 LosslessNumber 타입을 사용하여 큰 정수(BigInt) 값을 정확하게 처리하는 것이 중요합니다.
오류 처리 및 보안 고려사항
- DB 접근 오류: JWK 조회 중 DB 오류 발생 시
InternalServerErrorException을 발생시켜 처리합니다. - JSON 파싱 오류: DB에서 가져온 JWK/Private Key 문자열이 유효한 JSON 형식이 아닐 경우 오류 처리합니다.
- JWK 유효성:
validateJwk를 통해 키의 필수 요소가 누락되지 않았는지 확인합니다. - PEM 변환 오류:
jwk-to-pem변환 중 발생할 수 있는 오류를 처리합니다. - JWT 서명/검증 오류:
jsonwebtoken라이브러리에서 발생하는 표준 오류(TokenExpiredError,JsonWebTokenError)를 적절히 처리하거나 상위 서비스로 전달합니다. - 키 관리: 데이터베이스에 저장된 Private Key의 보안에 각별히 유의해야 합니다. 접근 제어, 암호화 등을 고려합니다.
- 캐싱: JWK 목록 캐싱 시 TTL을 적절히 설정하여 키 변경 사항이 적시에 반영되도록 합니다.