로그인 보안, HTTPS(SSL)만으로 안전할까?(feat. 클라이언트 사이드 RSA 암호화 적용기)

고예진·2025년 12월 29일
post-thumbnail

현재 인턴으로 근무하며 주간 발표 중 로그인 로직에 대해 이야기를 나누었다.

나는 "클라이언트에서 사용자 입력값을 서버로 전송하면, 서버가 이를 해시값으로 변경해 DB에 저장합니다"라고 설명했다. 그러자 매니저님께서 "그렇게 되면 클라이언트에서 서버로 전송되는 중간에 패킷이 가로채일 경우, 사용자의 평문 비밀번호가 그대로 노출될 수 있겠네요."라는 피드백을 주셨다.

솔직히 처음에는 "SSL(HTTPS)이 적용되어 있으면 암호화되니까 괜찮은 거 아닌가?"라고 생각했다. 실제로 SSL은 중간자 공격(MTM)을 통해 패킷이 탈취되어도, 그 내용을 보호해 주는 것은 맞다.

하지만 매니저님의 피드백을 통해, 내가 간과하고 있던 보안 위협을 알게 되었다.

바로 SSL이 보호해 주지 못하는 영역, '클라이언트 사이드'에서의 평문 노출 문제였다. 만약 악성 브라우저 확장 프로그램이나 XSS(Cross-Site Scripting) 공격이 성공한다면, 데이터가 '전송'되기 직전에 평문 비밀번호가 그대로 탈취될 수 있었다.

그래서 이 문제를 해결하기 위해, 클라이언트 단에서부터 데이터를 직접 암호화하는 RSA 비대칭키 암호화를 도입하기로 결정했다.


비대칭키 암호화(RSA) 도입

RSA(Rivest-Shamir-Adleman)는 비대칭키 암호화 알고리즘으로, 공개 키로 암호화하고 개인 키로 복호화 하는 방식

공개 키는 자유롭게 배포할 수 있기 때문에 키 교환 과정에서의 보안 문제가 없다. 클라이언트는 서버의 공개 키로 데이터를 암호화해 전송하면, 서버만이 이를 복호화할 수 있다.

우선 비대칭키 암호화(RSA)를 통해 패스워드를 암호화하는 경우의 순서는 다음과 같다.

  1. 로그인 페이지 로드 시, 클라이언트가 공개키를 요청한다.
  2. 서버에서 공개키와 개인키를 생성 후, 클라이언트에게 공개키를 전송한다.
  3. 클라이언트에서 사용자가 입력한 패스워드를 공개키로 암호화한 후 서버에게 전송한다.
  4. 서버는 암호화된 패스워드를 개인키로 복호화한다.
  5. 이후 DB에 저장된 패스워드의 해시값과 비교(인증)한다.

백엔드 구현: RSA 키 쌍 생성

프론트엔드에서 암호화를 수행하기 위해서는 먼저 서버에서 신뢰할 수 있는 키 쌍을 생성해야 한다. 백엔드 구현은 Liferay의 EncryptionUil 로직을 참고하여 구현했다.

서버는 java.security.KeyPairGenerator를 통해 2048비트의 RSA 키 쌍을 생성한다. 여기서 중요한 점은 개인키는 서버 메모리에만 안전하게 보관하고, 공개키만 클라이언트에 노출한다.

// 서버 사이드: RSA 키 쌍 생성 로직 (Liferay 기반)
public static KeyPair generateKeyPair() throws Exception {
    KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
    generator.initialize(2048); // 2048비트 보안 수준 설정
    return generator.generateKeyPair();
}

// 공개키를 Base64 문자열로 변환하여 프론트엔드에 전달할 준비
public static String getPublicKeyBase64(PublicKey publicKey) {
    return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}

프론트엔드 구현: 암호화 프로토콜 설계

[공개키 로드 및 포맷팅]

서버에서 보내주는 공개키는 순수 Base64 문자열이다. 하지만 JSEncrypt 라이브러리는 PEM 형식(헤더와 푸터가 포함된 형태)을 기대했기 때문에, 서버에서 받은 키를 프론트엔드 단에서 규격에 맞게 가공해야한다.

// 프론트엔드: 서버에서 받은 Base64 키를 PEM 형식으로 변환하여 설정
const encryptPassword = (plainPassword, publicKeyBase64) => {
  const encryptor = new JSEncrypt();
  
  // PEM 형식 규격에 맞게 헤더/푸터 추가
  const pemKey = `-----BEGIN PUBLIC KEY-----\n${publicKeyBase64}\n-----END PUBLIC KEY-----`;
  encryptor.setPublicKey(pemKey);

  const encrypted = encryptor.encrypt(plainPassword);
  return encrypted;
};

클라이언트 단에서 사용자 입력값을 난수 형태의 암호문으로 변환하는 데 성공했다.


암호화된 데이터를 서버로 전송하는 순간, 에러가 발생했다.

javax.crypto.BadPaddingException: Decryption error

클라이언트 사이드에서는 암호문이 정상적으로 생성되었지만 서버 로그에는 위와 같은 에러가 나타났다.

원인은 Padding(패딩) 방식의 불일치였다.

RSA 암호화는 보안을 위해 평문에 무작위 데이터를 채워넣는데, 서버와 클라이언트의 기본 설정이 달랐던 것이다.

  • 클라이언트(JSEncrypt): 기본적으로 PKCS#1 v1.5 패딩을 사용
  • 서버(Java): Cipher.getInstance("RSA")로 설정할 경우, 환경에 따라 기본 패딩 값이 달라져 클라이언트가 보낸 패딩 구조를 해석하지 못함

이 문제를 해결하기 위해 백엔드 담당자분과 소통하여, 양측의 암호화 알고리즘 규격을 RSA/ECB/PKCS1Padding으로 통일하기로 했다.

이후로는 서버에서 성공적으로 복호화된 평문 데이터를 확인할 수 있었다.


최종적으로 유저가 로그인 버튼을 누르면 평문 비밀번호는 즉시 암호문으로 변환되어 전송된다. 추가적으로 암호화 직후 평문이 담긴 변수는 즉시 비우도록 처리했으며, 네트워크 탭에서도 실제 비밀번호를 유추할 수 없는 난수만이 노출되는 것을 확인했다.

0개의 댓글