개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › 보안

암호화

암호화는 무엇이고, 열쇠가 없으면 어떻게 되는가

암호화는 글자를 열쇠와 함께 계산해서 알아볼 수 없는 값으로 바꾸는 것입니다. 코드에서는 encrypt("카드번호 1234", key)처럼 글자와 열쇠(key)를 넘기면 알아볼 수 없는 값이 돌아옵니다. 같은 열쇠를 가진 쪽이 decrypt(sent, key)처럼 그 값과 열쇠를 넘기면 원래 글자를 되찾습니다. 이것을 복호화라고 합니다. 열쇠가 없거나 다른 열쇠를 가진 쪽은 되찾지 못하므로, 지켜야 하는 것은 값이 아니라 열쇠입니다. 이 실습은 받는 쪽이 가진 열쇠를 같은 것, 다른 것, 없는 것으로 바꾸고 암호화 자체를 빼기도 하면서, 중간에서 보이는 값과 받는 쪽이 읽은 것을 실제로 실행해 확인합니다. (열쇠를 만드는 newKey와 계산하는 encrypt, decrypt의 본문은 화면에서 줄였습니다.)

실행으로 대조 · JDK 21에서 여섯 가지 조합을 javax.tools로 컴파일하고 실행해 대조, Node.js와 NIST 시험 벡터로 계산 확인

이 실습에 나오는 개념

암호화
암호화는 글자(평문)를 열쇠와 함께 계산해서 알아볼 수 없는 값(암호문)으로 바꾸는 것이다. 같은 열쇠를 가진 쪽은 그 값을 복호화해서 원래 글자를 되찾지만, 열쇠가 없으면 되찾지 못한다. 암호화하지 않고 보내거나 저장한 글자는 중간에서 누구나 그대로 읽는다. 해시는 누구도 원래 글자로 되돌리지 못하게 하는 것이고, 암호화는 열쇠를 가진 쪽만 되돌리게 하는 것이라는 점이 다르다. AES는 FIPS 197이 정한, 암호화와 복호화를 모두 하는 대칭 블록 암호이고, 자바에서는 javax.crypto.Cipher 클래스로 쓴다.
복호화
복호화는 암호화한 값을 열쇠와 함께 계산해서 원래 글자로 되돌리는 것이다. 대칭 방식에서는 암호화할 때 쓴 것과 같은 열쇠를 넣어야 한다. AES-GCM처럼 값에 확인 값(태그)을 붙이는 방식은 열쇠가 다르거나 값이 바뀌어 태그가 맞지 않으면 글자 대신 실패를 알리고(SP 800-38D의 FAIL), 자바의 Cipher는 이때 AEADBadTagException을 던진다. 확인 값이 없는 방식에서는 열쇠가 달라도 예외 없이 엉뚱한 바이트가 나올 수 있으므로, 복호화가 끝났다는 것만으로 맞는 열쇠였다고 믿으면 안 된다.
열쇠(키)
열쇠(키)는 암호화와 복호화 계산에 글자나 값과 함께 넣는 값이다. AES는 128, 192, 256비트 길이의 열쇠를 쓴다. 알고리즘은 FIPS 197처럼 공개된 표준이고, 값을 풀 수 있는지는 열쇠를 가졌는지에 달려 있으므로 지켜야 하는 것은 열쇠이다. 대칭 방식에서는 보내는 쪽과 받는 쪽이 같은 열쇠를 갖고, 그 열쇠를 안전하게 나눠 갖는 일이 따로 필요하다. GCM에서는 같은 열쇠로 암호화할 때마다 IV(nonce)를 달리해야 하며, 되풀이하면 위조 공격에 취약해진다(SP 800-38D 8절, Cipher 문서).
대칭키 암호화와 비대칭키 암호화
대칭키 암호화는 암호화와 복호화에 같은 키를 쓰는 방식으로 빠르지만 키를 상대와 안전하게 공유해야 하는 문제가 있다(AES). 비대칭키 암호화는 공개키와 개인키의 쌍을 쓰는 방식으로, 공개키로 암호화한 것은 개인키로만 풀 수 있어 키 공유 문제가 없지만 느리다(RSA). 실제로는 비대칭키로 대칭키를 안전하게 교환한 뒤 대칭키로 데이터를 암호화한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. SecretKey key = newKey(); 줄을 실행합니다. 도우미 메서드 newKey()가 열쇠를 하나 만들어 key에 담습니다. 열쇠는 글자를 암호화할 때와 풀 때 계산에 함께 넣는 값입니다.
  2. String sent = encrypt("카드번호 1234", key); 줄에서 보내는 쪽이 글자 "카드번호 1234"와 열쇠 key를 encrypt에 넣습니다. encrypt는 글자를 열쇠와 함께 계산해서 다른 값으로 바꿉니다.
  3. 계산이 끝나고 알아볼 수 없는 값이 나와 sent에 담깁니다. 이 실습에서는 ih/DXie50GTx…로 시작하는 60자의 글자가 나왔습니다. 이 값에서는 "카드번호 1234"를 읽을 수 없습니다. 같은 글자를 같은 열쇠로 다시 암호화하면 다른 값이 나오므로, 이 값은 한 예입니다.
  4. sent의 값이 중간(네트워크나 저장소)을 지나 받는 쪽으로 갑니다. 중간에서 보이는 것은 이 값뿐입니다. 글자와 열쇠는 중간으로 보내지 않았으므로 보이지 않습니다.
  5. String read = decrypt(sent, key); 줄에서 받는 쪽이 받은 값 sent와 같은 열쇠 key를 decrypt에 넣습니다. decrypt는 암호화의 반대 계산인 복호화를 합니다.
  6. 복호화 계산 결과로 원래 글자 "카드번호 1234"가 나와 read에 담깁니다. 암호화할 때 쓴 열쇠와 같은 열쇠를 넣었기 때문입니다.
  7. System.out.println(sent)가 알아볼 수 없는 값 60자 전체를 출력하고, System.out.println(read)가 카드번호 1234를 출력합니다.

실행 결과

코드

SecretKey key = newKey();                       // 열쇠를 하나 만든다

// 보내는 쪽: 글자를 열쇠로 암호화한다
String sent = encrypt("카드번호 1234", key);

// 받는 쪽: 같은 열쇠로 푼다
String read = decrypt(sent, key);

System.out.println(sent);
System.out.println(read);

이 모형의 전제

자바 JDK 21.0.12.1의 javax.crypto.Cipher("AES/GCM/NoPadding")로 실제 계산했고, 열쇠는 256비트, 태그는 128비트, 암호화마다 새로 만드는 12바이트 nonce를 결과 앞에 붙여 Base64로 적었습니다. 화면에서 줄인 것은 import, main의 선언, 도우미 메서드 newKey, encrypt, decrypt의 본문입니다. 여섯 가지 조합을 모두, 화면의 코드와 같은 줄을 main에 담은 소스를 javax.tools로 컴파일하고 클래스를 새 클래스 로더로 읽어 Main.main을 실행해, 표준 출력과 던져진 예외를 잡은 결과와 대조했습니다. 대조한 것은 중간에서 보이는 값(encrypt가 돌려준 값의 앞 12자와 전체 글자 수), 받는 쪽이 읽은 것, 원래 글자를 되찾았는지, 다른 열쇠로 풀 때 던져진 예외의 이름(javax.crypto.AEADBadTagException, 메시지 Tag mismatch)과 그때 아무것도 출력되지 않는 것입니다. 암호화한 값은 실행할 때마다 달라집니다. 대조를 위해 열쇠와 nonce를 정해진 값으로 고정해 같은 값이 나오게 했고(실제로는 둘 다 무작위로 만듭니다), 화면의 ih/DXie50GTx…(60자)는 그렇게 얻은 한 예입니다. 같은 글자를 같은 열쇠로 nonce를 새로 만들어 두 번 암호화하면 두 값이 서로 달랐고 둘 다 풀렸으며, 무작위로 만든 열쇠와 nonce로 여섯 조합을 다시 실행해도 결과의 종류(읽은 글자, 예외 이름, 값의 길이)는 같았습니다. 화면에 보이는 앞 12자는 값 앞에 붙은 nonce 부분(앞 9바이트)이고, 암호화된 글자 부분은 그 뒤에 이어집니다. 값의 길이는 글자의 길이에 따라 달라집니다. 계산이 표준대로인지는 두 가지로 확인했습니다. 같은 열쇠, nonce, 글자를 Node.js의 aes-256-gcm으로도 암호화해 암호문과 태그가 자바와 바이트까지 같았고, Node.js가 자바의 값을 같은 열쇠로 풀고 다른 열쇠로는 풀지 못했습니다. 또 NIST 암호 알고리즘 검증 프로그램(CAVP)이 공개한 GCM 시험 벡터(gcmtestvectors.zip의 gcmEncryptExtIV256.rsp와 gcmDecrypt256.rsp, 열쇠 256비트, IV 96비트, 글자 128비트, 추가 데이터 없음, 태그 128비트)에서 암호화 한 건의 암호문과 태그, 복호화 두 건(통과 한 건, FAIL 한 건)의 결과가 자바 계산과 같았습니다. 문서는 직접 열어 확인했습니다. NIST FIPS 197(2023년 갱신판) 머리말 3항은 AES를 암호화하고 복호화할 수 있는 대칭 블록 암호로 소개하고, 열쇠 길이는 128, 192, 256비트이며 데이터는 128비트 블록 단위라고 적습니다. NIST SP 800-38D(2007년판)는 3절에서 GCM이 데이터의 기밀성과 인증을 함께 보장한다고 하고, 5.2.2절과 7.2절에서 인증된 복호화는 확인 값(태그)이 맞으면 글자를, 맞지 않으면 글자 대신 FAIL을 내놓는다고 정하며, 8절에서 같은 열쇠와 같은 IV로 서로 다른 데이터를 암호화하는 일이 일어날 확률을 2의 -32제곱 이하로 하라고 요구하고, 5.2.1.1절에서 IV 길이는 96비트를 권장합니다. Java 21의 Cipher 문서는 AEAD 방식(GCM 등)에서 태그가 암호문 뒤에 붙고 복호화할 때 확인된다고 설명하고, 같은 열쇠로 암호화할 때마다 IV를 바꾸라고 안내하며, AEADBadTagException 문서는 AEAD 방식의 Cipher가 태그를 확인하지 못할 때 던져진다고 설명합니다. 이 실습은 보내는 쪽과 받는 쪽이 같은 열쇠를 쓰는 방식(대칭 키 암호화)만 다룹니다. 열쇠가 둘인 방식(공개 키)과 서명은 "세부 동작 실습: 해시·암호화·서명"에서 다루고, 받는 쪽이 같은 열쇠를 어떻게 안전하게 갖게 되는지(열쇠 전달)는 다루지 않습니다. 다른 열쇠로 풀 때 예외가 나는 것은 이 실습이 고른 방식(AES-GCM)이 값이 바뀌었거나 열쇠가 다른지를 태그로 함께 확인하기 때문입니다. 그런 확인이 없는 방식에서는 다를 수 있어서, 다른 열쇠로 AES/CTR/NoPadding을 풀어 보았더니 예외 없이 원래 글자가 아닌 다른 바이트가 나왔습니다. 열쇠를 안전하게 보관하는 방법은 다루지 않았습니다.

앞 실습: 해시 · 다음 실습: 출처(origin)

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요