본문으로 건너뛰기

앱 보안 인증 시스템 기술 명세

1. 개요

이 문서는 클라이언트 앱의 무결성 검증 및 안전한 인증을 위한 앱 보안 인증 시스템의 기술적 명세를 제공합니다. 주요 내용은 다음과 같습니다:

  1. 디바이스 ID(Device ID): 클라이언트 장치를 고유하게 식별하고 인증하기 위한 보안 메커니즘
  2. 앱 무결성 검증: 클라이언트 앱이 변조되지 않았는지 확인하는 프로세스
  3. RSA 기반 챌린지-응답 인증: 앱과 서버 간 안전한 통신을 위한 비대칭 암호화 인증 메커니즘
  4. 암호화/복호화 프로세스: Device ID 및 관련 정보의 안전한 전송과 저장을 위한 암호화 방식

이 문서는 앱 보안 인증 시스템의 전체적인 아키텍처와 흐름, 구현 방법을 설명하며, 앱 토큰 발급 과정과의 연계 방식도 다룹니다. 앱 토큰에 대한 상세 내용은 앱 토큰 기술 명세를 참조하세요.

2. Device ID 형식

2.1 기본 구조

interface DeviceIdentifier {
uuid: string; // 장치 고유 식별자
platform: string; // 플랫폼 정보 (iOS, Android, web)
version: string; // 클라이언트 앱 버전
timestamp: number; // 생성 시간 (Unix timestamp)
}

2.2 해싱 및 직렬화 형식

Device ID는 보안 및 관리 목적으로 해싱 후 저장 및 전송됩니다:

// 디바이스 정보 직렬화
const deviceIdString = JSON.stringify(deviceIdentifier);

// SHA-256 해싱 후 16진수(hex) 문자열로 인코딩
const deviceIdHash = crypto.createHash('sha256')
.update(deviceIdString)
.digest('hex');

해싱된 Device ID를 사용하는 주요 장점:

  1. 고정 길이: 해시는 원본 데이터 크기와 관계없이 항상 고정된 길이(SHA-256 후 hex 인코딩 시 64자)로 생성됩니다.
  2. 데이터베이스 인덱싱: 고정 길이 문자열이므로 데이터베이스 인덱싱 효율이 향상됩니다.
  3. 충돌 방지: 암호학적 해시 함수는 충돌 가능성이 극히 낮아 고유 식별자로 적합합니다.
  4. 일관성: 클라이언트와 서버 간에 동일한 해싱 알고리즘(SHA-256)과 인코딩 방식(hex)을 사용하여 검증을 수행합니다.
  5. 디바이스 정보 은닉: 원본 디바이스 정보가 직접 노출되지 않습니다.

주의: 현재 구현은 hex 인코딩을 사용합니다. 만약 base64url 인코딩(digest('base64url'))을 사용하려면 클라이언트와 서버 구현 모두 일관되게 수정해야 합니다.

보안을 더 강화하기 위해 솔트(salt)를 추가할 수도 있습니다:

// 솔트 추가 해싱 (앱 시크릿을 솔트로 사용)
const deviceIdHashWithSalt = crypto.createHash('sha256')
.update(`${deviceIdString}:${APP_SECRET}`)
.digest('base64url');

3. RSA 기반 챌린지-응답(Challenge-Response) 인증 메커니즘

이 장에서는 앱의 무결성 검증을 위한 RSA 기반 챌린지-응답 메커니즘을 설명합니다.

3.1 개요

챌린지-응답 메커니즘은 서버가 클라이언트에게 랜덤 챌린지를 제공하고, 클라이언트가 서버의 공개키로 이를 암호화하여 응답을 생성하는 방식입니다. 서버는 자신의 개인키로 이 응답을 복호화하여 무결성을 증명합니다. 이 메커니즘을 통해 다음을 확인할 수 있습니다:

  1. 클라이언트가 정당한 앱인지 (변조되지 않았는지)
  2. 디바이스 ID가 유효한지
  3. 실시간 응답인지 (재생 공격 방지)

3.2 프로세스 흐름

챌린지-응답 인증 프로세스는 다음과 같은 순서로 진행됩니다:

  1. 챌린지 요청: 클라이언트가 자신의 Device ID와 함께 서버에 챌린지를 요청합니다.
  2. 챌린지 생성: 서버는 랜덤 챌린지와 논스(nonce)를 생성하고 클라이언트에게 반환합니다.
  3. 응답 생성: 클라이언트는 서버의 공개키를 사용하여 RSA 방식으로 챌린지를 암호화합니다.
  4. 응답 검증: 서버는 개인키로 클라이언트의 응답을 복호화하고 유효성을 검증합니다.
  5. 토큰 발급: 검증이 성공하면 서버는 앱 토큰을 발급합니다.

3.3 RSA 기반 챌린지-응답 상세

3.4 RSA 암호화/복호화 구현

클라이언트 측 (Swift) - 암호화

// CryptoHelper.swift
// RSA 공개키 로드
static func loadServerPublicKey() throws -> SecKey {
// PEM 형식 키에서 헤더/푸터 제거 및 Base64 디코딩
let publicKeyString = serverPublicKey
.replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
.replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
.replacingOccurrences(of: "\n", with: "")

guard let keyData = Data(base64Encoded: publicKeyString) else {
throw ErrorType.publicKeyLoadFailed
}

// X.509 SubjectPublicKeyInfo 형식으로 변환
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits as String: 2048
]

guard let publicKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, nil) else {
throw ErrorType.publicKeyLoadFailed
}

return publicKey
}

// RSA 암호화
static func encryptRSA(_ input: String) throws -> String {
guard let inputData = input.data(using: .utf8) else {
throw ErrorType.invalidDataFormat
}

let publicKey = try loadServerPublicKey()

var error: Unmanaged<CFError>?
guard let encryptedData = SecKeyCreateEncryptedData(
publicKey,
.rsaEncryptionPKCS1,
inputData as CFData,
&error
) as Data? else {
throw error?.takeRetainedValue() as? Error ?? ErrorType.rsaEncryptionFailed
}

return encryptedData.base64EncodedString()
}

서버 측 (Node.js) - 복호화

// 개인키로 암호화된 챌린지를 복호화합니다.
private decryptChallenge(encryptedChallenge: string): string {
// Base64 디코딩
const encryptedBuffer = Buffer.from(encryptedChallenge, 'base64');

// RSA 복호화
const decrypted = privateDecrypt(
{
key: this.privateKey,
padding: constants.RSA_PKCS1_PADDING,
},
encryptedBuffer
);

// UTF-8 문자열로 변환
return decrypted.toString('utf8');
}

이 메커니즘의 핵심 보안 특성:

  1. 비대칭 암호화: 서버만이 개인키를 보유하므로, 암호화된 챌린지를 복호화할 수 있는 유일한 주체
  2. 중간자 공격 방지: 클라이언트가 서버의 인증된 공개키를 가지고 있어 위조 불가능
  3. 기기 바인딩: 키는 특정 디바이스 ID에 바인딩됨
  4. 요청 고유성: 매 요청마다 고유한 논스 사용

주의: 위 코드는 실제 구현 예시입니다. 서버 측 구현은 다음 문서를 참조하십시오:

  • 백엔드: 앱 인증 시스템 구현 가이드
  • iOS: 앱 인증 시스템 구현 가이드의 클라이언트 구현 섹션 참조
  • Android: 앱 인증 시스템 구현 가이드의 클라이언트 구현 섹션 참조

4. 앱 무결성 검증 및 인증

이 장에서는 Device ID의 검증과 앱 무결성 확인을 통한 앱 토큰 발급 과정을 설명합니다. 구체적인 앱 토큰 발급 방법 및 구현에 대한 상세 내용은 앱 토큰 기술 명세를 참조하세요.

4.1 Device ID 검증

서버는 클라이언트로부터 받은 Device ID를 다음과 같이 검증합니다:

  1. 원본 데이터 검증: 디바이스 정보를 재해싱하여 일치 여부 확인
  2. 형식 검증: 유효한 SHA-256 해시 형식인지 확인 (64자 16진수)
  3. 필수 필드 확인: UUID, 플랫폼, 버전 등 필수 정보 존재 여부 확인
// 함수 구현 참조: libs/feature/auth/src/lib/domain/services/device-identifier.service.ts 파일의 validateDeviceId 메서드
function validateDeviceIdHash(hashedDeviceId: string, deviceInfo: Record<string, any>): boolean {
// 1. 해시 형식 검증 (64자 16진수)
if (!/^[0-9a-f]{64}$/i.test(hashedDeviceId)) {
return false;
}

// 2. 필수 필드 검증
if (!deviceInfo.uuid || !deviceInfo.platform || !deviceInfo.version) {
return false;
}

// 3. 해시 일치 검증
const computedHash = hashDeviceId(deviceInfo);
return computedHash === hashedDeviceId;
}

4.2 챌린지 검증

서버는 클라이언트가 제공한 암호화된 챌린지를 다음 단계로 검증합니다:

  1. 캐시 확인: 논스에 해당하는 원본 챌린지 조회
  2. 적시성 확인: 챌린지가 유효 시간 내에 응답되었는지 확인
  3. RSA 복호화: 서버의 개인키로 암호화된 챌린지 복호화
  4. 일치 확인: 복호화된 챌린지와 원본 챌린지 비교
// 함수 구현 참조: libs/feature/auth/src/lib/domain/services/challenge-response.service.ts 파일의 validateResponse 메서드
async function verifyChallenge(
deviceId: string,
encryptedChallenge: string,
nonce: string
): Promise<boolean> {
// 1. 캐시에서 원본 챌린지 검색
const cachedData = await cacheManager.get(`challenge:${deviceId}:${nonce}`);
if (!cachedData) {
return false; // 캐시된 챌린지 없음 (만료 또는 유효하지 않은 논스)
}

// 2. 캐시에서 가져온 챌린지
const { challenge } = cachedData;

// 3. 검증 후 캐시 삭제 (일회성)
await cacheManager.del(`challenge:${deviceId}:${nonce}`);

// 4. 암호화된 챌린지 복호화
const decryptedChallenge = decryptChallenge(encryptedChallenge);

// 5. 원본과 복호화된 챌린지 비교
return challenge === decryptedChallenge;
}

4.3 앱 토큰 발급

챌린지 검증이 성공하면 서버는 앱 토큰을 발급합니다. 앱 토큰 발급과 관련된 상세 내용은 앱 토큰 구현 가이드를 참조하세요.

// 함수 구현 참조: libs/feature/auth/src/lib/domain/services/app-token.service.ts 파일의 generateAppToken 메서드
function generateAppToken(deviceId: DeviceIdentifier): string {
// 토큰 페이로드 생성
const payload = {
sub: deviceId.getValue(),
type: 'app_token',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + APP_TOKEN_EXPIRATION
};

// JWT 토큰 생성
return jwtService.sign(payload, {
secret: APP_TOKEN_SECRET
});
}

5. 앱 보안 인증 API

앱 보안 인증 시스템은 다음 API 엔드포인트를 통해 구현됩니다:

5.1 챌린지 요청 및 완료 API

챌린지 요청:

POST /de/v1/auth/app/challenge

챌린지 완료 및 앱 토큰 발급:

POST /de/v1/auth/app/complete-challenge 

6. 변경 이력

버전날짜작성자변경 내용
0.1.02025-03-15bok@weltcorp.com최초 작성
0.2.02025-03-20bok@weltcorp.comChaCha20 암호화 관련 내용 추가
0.3.02025-03-21bok@weltcorp.comGCP Cloud KMS 기반 키 관리 및 모바일 앱 인증 방법 추가
0.4.02025-03-25bok@weltcorp.comDCAppAttestService/Play Integrity API 대신 시간 기반 OTP 챌린지 인증 메커니즘 추가
0.5.02025-04-15bok@weltcorp.com대칭 키 기반 메커니즘에서 RSA 비대칭 암호화 방식으로 변경