Blog / Java/Kotlin / RSA 클라이언트 암호화 적용

RSA 클라이언트 암호화 적용

웹 애플리케이션에서 로그인 기능을 구현할 때, HTTPS를 사용하고 있으니 비밀번호 전송이 안전하다고 생각하기 쉽다. 물론 HTTPS는 전송 구간을 암호화해주지만, 브라우저 개발자 도구의 Network 탭을 열어보면 이야기가 달라진다.

text
POST /api/auth/login

{
  "email": "[email protected]",
  "password": "mySecret123!"   ← 평문 그대로 노출
}

요청 페이로드에 비밀번호가 그대로 찍힌다. 화면 공유 중이거나, 프록시 로깅이 켜져 있거나, 브라우저 확장 프로그램이 요청을 가로채는 상황이라면 비밀번호가 의도치 않게 유출될 수 있다.

RSA 비대칭 암호화를 활용하여, 클라이언트에서 비밀번호를 암호화한 후 서버에 전송하는 방법을 정리해본다.


RSA

비대칭 암호화(공개키 암호화)의 개념은 이렇다.

  • 공개키 — 누구나 가질 수 있고, 암호화에 사용
  • 개인키 — 서버만 보유하고, 복호화에 사용

공개키가 노출되더라도 개인키 없이는 복호화할 수 없으므로, 클라이언트에 공개키를 자유롭게 내려줄 수 있다. AES 같은 대칭키 방식은 암호화와 복호화에 같은 키를 사용하기 때문에, 클라이언트에 키를 전달하는 것 자체가 보안 문제가 된다.


전체 흐름


구현
1단계: RSA 키 쌍 생성 (서버)

애플리케이션 시작 시 RSA 2048-bit 키 쌍을 메모리에 생성한다.

java
public final class RsaKeyHolder {
    private static final KeyPair KEY_PAIR;

    static {
        try {
            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
            generator.initialize(2048);
            KEY_PAIR = generator.generateKeyPair();
        } catch (Exception e) {
            throw new RuntimeException("RSA 키 쌍 생성 실패", e);
        }
    }

    public static String getPublicKeyBase64() {
        return Base64.getEncoder().encodeToString(
            KEY_PAIR.getPublic().getEncoded()
        );
    }
}

static 초기화 블록을 사용해서 클래스 로딩 시점에 한 번만 키를 생성한다. 애플리케이션이 재시작되면 새로운 키 쌍이 만들어지므로, 별도의 키 파일 관리가 필요 없다.

2단계: 공개키 API (서버)

클라이언트가 암호화에 사용할 공개키를 요청하는 엔드포인트를 추가한다.

kotlin
@GetMapping("/public-key")
fun getPublicKey(): PublicKeyResponse {
    return PublicKeyResponse.of(RsaKeyHolder.getPublicKeyBase64())
}

이 엔드포인트는 인증 없이 접근할 수 있어야 한다. 로그인 전에 호출되므로.

3단계: 비밀번호 암호화 (클라이언트)

브라우저의 Web Crypto API를 사용하여 비밀번호를 암호화한다. 별도의 외부 라이브러리 없이 브라우저 내장 API만으로 구현할 수 있다.

javascript
async function doLogin() {
    var email = document.getElementById('login-email').value.trim();
    var password = document.getElementById('login-password').value;

    // 1. 서버에서 공개키 가져오기
    var keyRes = await fetch('/api/auth/public-key');
    var keyData = await keyRes.json();

    // 2. Base64 문자열 → ArrayBuffer → CryptoKey 변환
    var binaryKey = Uint8Array.from(
        atob(keyData.publicKey),
        function(c) { return c.charCodeAt(0); }
    );
    var cryptoKey = await crypto.subtle.importKey(
        'spki',
        binaryKey.buffer,
        { name: 'RSA-OAEP', hash: 'SHA-256' },
        false,
        ['encrypt']
    );

    // 3. 비밀번호를 RSA-OAEP로 암호화
    var encrypted = await crypto.subtle.encrypt(
        { name: 'RSA-OAEP' },
        cryptoKey,
        new TextEncoder().encode(password)
    );

    // 4. 암호화된 결과를 Base64로 인코딩하여 전송
    var encryptedBase64 = btoa(
        String.fromCharCode.apply(null, new Uint8Array(encrypted))
    );

    fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email: email, password: encryptedBase64 })
    });
}

핵심은 crypto.subtle.importKey로 서버의 공개키를 가져온 뒤, crypto.subtle.encrypt로 RSA-OAEP 암호화를 수행하는 부분이다. spki는 서버가 내려준 공개키의 포맷(X.509 SubjectPublicKeyInfo)을 의미한다.

4단계: 복호화 및 인증 (서버)

서버에서는 전달받은 암호화된 비밀번호를 개인키로 복호화한 후 Spring Security 인증을 수행한다.

java
public static String decrypt(String encryptedBase64) {
    try {
        PrivateKey privateKey = KEY_PAIR.getPrivate();
        Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
        OAEPParameterSpec oaepParams = new OAEPParameterSpec(
            "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
        );
        cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams);
        byte[] decryptedBytes = cipher.doFinal(
            Base64.getDecoder().decode(encryptedBase64)
        );
        return new String(decryptedBytes);
    } catch (Exception e) {
        throw new RuntimeException("RSA 복호화 실패", e);
    }
}
kotlin
// AuthService.kt
fun getSecurityContext(loginRequest: LoginRequest): SecurityContext {
    val decryptedPassword = RsaKeyHolder.decrypt(loginRequest.password)
    val authentication = authenticationManager.authenticate(
        UsernamePasswordAuthenticationToken(loginRequest.email, decryptedPassword)
    )
    // ...
}

기존 코드에서 변경되는 부분은 단 한 줄, loginRequest.passwordRsaKeyHolder.decrypt(loginRequest.password)로 바꾸는 것이다.


주의: Java와 Web Crypto API의 OAEP 파라미터 불일치

구현 과정에서 처음에 OAEPParameterSpec 없이 단순하게 작성하면,

java
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.DECRYPT_MODE, privateKey);

BadPaddingException: Padding error in decryption이 발생한다.

원인은 Java와 Web Crypto API가 사용하는 기본 해시 알고리즘이 다르기 때문이다.

OAEP 해시 MGF1 해시
Java 기본값 SHA-256 SHA-1
Web Crypto API SHA-256 SHA-256

Java에서 OAEPWithSHA-256AndMGF1Padding을 지정하면 OAEP 해시는 SHA-256을 사용하지만, MGF1 해시는 기본값인 SHA-1을 사용한다. 반면 Web Crypto API에서 hash: 'SHA-256'을 지정하면 OAEP 해시와 MGF1 해시 모두 SHA-256을 사용한다.

이 불일치가 복호화 실패를 일으킨다. 해결 방법은 OAEPParameterSpec으로 MGF1 해시를 명시적으로 SHA-256으로 지정하는 것.

java
// ✗ 이렇게 하면 MGF1이 SHA-1을 사용 → Web Crypto API와 불일치
cipher.init(Cipher.DECRYPT_MODE, privateKey);

// ✓ MGF1도 SHA-256으로 명시 → Web Crypto API와 일치
OAEPParameterSpec oaepParams = new OAEPParameterSpec(
    "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
);
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams);

적용 결과

적용 후 Network 탭을 확인하면, 비밀번호가 더 이상 평문으로 노출되지 않는다.

text
POST /api/auth/login

{
  "email": "[email protected]",
  "password": "hK7xJ9v2mQ4pR8sT..."   ← RSA 암호화된 문자열
}

이 방식의 한계점
  • HTTPS를 대체하지 않는다. HTTPS 없이 이 방식만으로는 중간자 공격(MITM)을 방어할 수 없다. 공개키 자체가 변조될 수 있다. 이 방식은 HTTPS 위에 추가하는 방어 계층이다.
  • 리플레이 공격에 취약하다. 암호화된 비밀번호를 탈취하여 그대로 재전송하면 로그인이 된다. 이를 방지하려면 타임스탬프나 nonce를 함께 암호화하는 방법을 고려할 수 있다.
  • 서버 재시작 시 키가 변경된다. 메모리에 키를 보유하므로, 재시작하면 이전 키로 암호화된 요청은 복호화할 수 없다. 로그인 요청은 실시간으로 처리되므로 실질적인 문제는 없지만, 인지하고 있어야 한다.

HTTPS가 전송 구간을 보호해주지만, 클라이언트 측에서 한 단계 더 암호화를 적용하면 개발자 도구 노출, 로깅, 프록시 등 다양한 경로에서의 비밀번호 유출 위험을 줄일 수 있다. Web Crypto API 덕분에 외부 라이브러리 없이도 브라우저에서 RSA 암호화를 구현할 수 있어 적용 부담도 크지 않다.

Written by
author
풍우래기

여행을 좋아하는 집돌이 개발자입니다.

블로그에 새로운 글이 발행되었습니다.