AES 복호화 실패: 키, IV, 모드, 패딩 해결 방법
로그에 AES 복호화 실패가 찍혔다면, 손에 쥔 오류 메시지는 십중팔구 엉뚱한 문제를 설명하고 있습니다. 서로 아무 관련도 없는 버그 네 가지가 거의 똑같은 증상을 만들어 내는데, 그중 가장 흔한 원인인 잘못된 키는 패딩 오류로 보고됩니다.
BadPaddingException을 던지는 CBC 복호화에서 출발할 때, 시간을 가장 많이 아껴 주는 점검 순서는 다음과 같습니다:
- 양쪽의 키 바이트가 서로 다릅니다. 다른 원인과는 비교가 안 될 만큼 가능성이 높습니다.
- 키 파생 방식이 다릅니다. 같은 패스프레이즈라도 KDF나 반복 횟수가 다르면 키 바이트가 달라집니다.
- 전송 중에 암호문이 손상됐습니다. 잘렸거나, Base64가 망가졌거나, 텍스트 인코딩을 한 번 거쳐 왔습니다.
- IV가 틀렸습니다. 실제로 일어나는 일이지만, 패딩 오류를 던지지는 않습니다. 16바이트를 조용히 망가뜨릴 뿐입니다.
이 순서는 구조에서 나옵니다. CBC는 키를 적용하고 체인을 풀어낸 뒤, 복호화의 마지막 단계에 가서야 패딩을 검사합니다. 그래서 패딩은 그 앞 단계 전부에 대한 체크섬 노릇을 하고, 앞쪽 어디가 깨졌든 상관없이 요란하게 실패합니다. 아래에서 실측값을 확인하시거나, 암호문을 AES 복호화 도구에 붙여 넣고 9절의 이분 탐색을 따라가 보세요.
아래 내용은 전부 java 1.8.0_162, node v25.8.2, openssl 3.6.2에서 측정했습니다. 기본값은 버전이 바뀌면 함께 움직이므로, 버전 번호도 결과의 일부로 보십시오.
1. 오류 메시지가 배제해 주는 것에서 출발하십시오
AES 실패 메시지는 원인에 대해 알려 주는 것이 거의 없는 대신, 원인이 될 수 없는 것에 대해서는 많은 것을 알려 줍니다. 분기를 고르는 데 쓰지 말고, 분기를 지우는 데 쓰세요.
| 보이는 메시지 | 배제되는 것 | 아직 살아 있는 것 |
|---|---|---|
BadPaddingException, bad decrypt, wrong final block length | GCM, 순수한 IV 실수, 디코딩 실패 | 잘못된 키, 잘못된 KDF, 잘린 암호문, IV 바이트를 암호문으로 먹은 경우, 모드 불일치, 패딩 방식 불일치 |
GCM Authentication failed, Unsupported state or unable to authenticate data | 패딩, 부분 출력을 전제로 한 모든 가설 | 잘못된 키, 잘못된 논스, 분리되었거나 위치가 어긋난 태그, 잘못된 태그 길이, 어긋난 AAD |
| 예외 없음, 출력이 쓰레기 | 모든 인증 모드 | ECB, CTR, 운 좋게 통과한 CBC, 모드 불일치, 잘못된 IV |
BadPaddingException, bad decrypt, wrong final block length
Java, OpenSSL, .NET 세 생태계가 같은 사건에 붙인 세 이름입니다. CBC나 ECB 복호화의 마지막 단계에서, 마지막 평문 블록이 유효한 PKCS#7 패턴으로 끝나지 않을 때 터집니다.
쓸모 있는 부분은 부정형입니다. 여기까지 왔다는 것은 Base64나 hex 디코딩이 성공했고 바이트 수가 0이 아닌 16의 배수였다는 뜻이니, 전송 과정이 데이터를 찢어 놓지도 않았고 GCM도 아닙니다. wrong final block length만 예외입니다. 이 경우 바이트 수가 16의 배수가 아니었다는 뜻이고, 이는 키가 아니라 잘림을 가리키므로 8절로 건너뛰십시오.
GCM의 Authentication failed 계열
GCM은 NIST SP 800-38D가 요구하는 대로, 평문을 단 1바이트도 내놓기 전에 태그를 비교합니다. 덕분에 패딩 오류가 갖지 못한 정직함이 있습니다. 실패했다면 (키, 논스, 암호문, 추가 인증 데이터, 태그)로 이루어진 튜플 중 무언가가 암호화 측이 쓴 값과 다르다는 뜻입니다. 어느 항목인지는 알려 주지 않고 앞으로도 알려 주지 않습니다. 범위를 좁혀 주는 일은 알고리즘의 설계에서 의도적으로 빠져 있습니다. 언어를 넘나들 때 가장 자주 깨지는 것은 태그의 값이 아니라 태그의 위치인데, 이는 6절에서 다룹니다.
오류는 없는데 출력이 쓰레기인 경우
대시보드가 성공으로 기록하기 때문에 가장 위험한 결말입니다. CTR은 절대 예외를 던지지 않고 ECB도 마찬가지입니다. CBC는 마지막 바이트 패턴이 패딩 검사를 통과하지 못할 때만 예외를 던지는데, 키가 틀리면 그 바이트는 사실상 난수라서 256번에 한 번꼴로 0x01이 나와 검사를 통과합니다. 키가 틀린 CBC 복호화의 0.4%가 조금 안 되는 비율이 “성공”합니다. 다만 쓰레기에도 모양이 있고, 그 모양을 보면 어느 버그인지 가려낼 수 있습니다. 외워 둘 만한 지문 두 개가 4절과 5절에 있습니다.
2. AES에서 가장 오해를 부르는 오류
대부분의 사람이 디버깅 우선순위를 다시 짜게 만드는 실측값입니다. 키 0123456789abcdef, 전부 0인 IV, AES/CBC/PKCS5Padding, 평문 hello world, java 1.8.0_162의 JDK 내장 SunJCE 프로바이더 기준입니다:
| 시나리오 | 바꾼 것 | 측정 결과 |
|---|---|---|
| A | 키가 1바이트 틀림(마지막 문자 f → X) | javax.crypto.BadPaddingException: Given final block not properly padded를 던집니다. 패딩 자체는 망가진 적이 없으니, 오류 메시지는 완전한 오해입니다 |
| B | 키는 맞고 IV가 1바이트 틀림 | 예외 없음, 평문 hello world가 iello world로 돌아왔습니다. 첫 블록의 해당 바이트 하나만 손상됐습니다 |
| C | 키는 맞고, CBC 암호문을 AES/ECB로 복호화 | 예외 없이 조용히 성공. 모드 불일치는 아무것도 일으키지 않을 수 있습니다 |
시나리오 A는 오후 한나절을 통째로 엉뚱한 곳에 쓰게 만듭니다. 시나리오 C는 잘못된 데이터를 그대로 운영 환경에 내보냅니다.
잘못된 키가 패딩 오류를 만드는 이유
패딩에는 아무 문제도 없었습니다. 암호화 측은 hello world를 16바이트로 채우려고 0x05 바이트 다섯 개를 덧붙였고, 그 블록을 암호화했으며, 그 결과는 지금도 암호문 안에 멀쩡히 들어 있습니다.
실패는 나오는 길에 벌어집니다. CBC 복호화는 블록 암호를 역방향으로 돌리고, 각 결과를 직전 암호문 블록과 XOR한 다음, 그제서야 마지막 블록의 끝부분을 읽어 몇 바이트를 떼어 낼지 정합니다. 키가 틀리면 암호는 16바이트짜리 잡음을 뱉고, 잡음이 유효한 PKCS#7 패턴으로 끝나는 일은 거의 없습니다. 라이브러리는 자기가 본 것을 그대로, 패딩이 잘못됐다고 보고합니다. 맞는 말이지만 원인을 찾는 데는 도움이 되지 않습니다.
BadPaddingException은 “내가 복원한 평문이 패딩된 평문이 끝나는 모양으로 끝나지 않는다”로 읽으십시오. 복원이 틀린 이유로 가장 가능성이 높은 것은 키이고, 그래서 aes decrypt wrong key로 검색하든 bad padding exception으로 검색하든 결국 같은 스레드에 도착합니다. 두 증상은 사실 하나입니다. 설계할 때는 이 구분을 호출자에게 절대 노출하지 마십시오. “패딩이 유효하지 않음”과 “패딩은 유효하지만 내용이 틀림”을 구별할 수 있다는 사실이야말로 패딩 오라클 공격이 먹고사는 양분이기 때문입니다(Vaudenay, EUROCRYPT 2002).
GCM은 무엇이 다른가
GCM은 순서를 뒤집습니다. 평문을 내놓기 전에 태그를 검증하므로, 부분적으로만 맞는 바이트가 존재하는 틈이 아예 없습니다. GCM 실패에서는 출력이 진짜인지 고민할 일이 없습니다. 출력 자체가 없기 때문입니다. 또한 GCM은 속을 들여다보면 카운터 모드라서 패딩이 전혀 없고, 암호문 길이가 평문 길이와 같습니다. 따라서 GCM인 줄 알았던 시스템에서 패딩 오류가 났다면 그 시스템은 GCM이 아니라는 증명이며, 대개는 설정이 CBC로 되돌아간 경우입니다.
3. 양쪽이 같은 키 바이트를 쓰고 있습니까?
AES는 키 문자열을 보지 않습니다. 16, 24, 32바이트를 볼 뿐입니다. 두 시스템이 설정 파일에 똑같은 키 자료를 담고 있어도 서로 어긋날 수 있습니다. “똑같다”는 것은 바이트가 아니라 텍스트의 성질이기 때문입니다.
키 문자열이 바이트로 바뀌는 세 가지 경로
같은 문자열 0123456789abcdef을 서로 다른 라이브러리 세 곳에 넘기면 이렇게 갈립니다:
hex로 읽으면 -> 8바이트 (AES 키 길이로 유효하지 않음)
base64로 읽으면 -> 12바이트 (AES 키 길이로 유효하지 않음)
raw UTF-8로 읽으면 -> 16바이트 (유효한 AES-128)
문자 16개에 바이트 수는 셋입니다. 이 사례가 고약한 이유는 세 가지 해석 모두에서 유효하기 때문입니다. 모든 문자가 hex 알파벳에도 Base64 알파벳에도 들어 있고, 16자는 두 디코더 모두에게 적법한 길이라서 파싱 시점에는 아무 오류도 나지 않습니다.
각 생태계가 비밀 키 문자열을 어떻게 해석하는지 정리한 라이브러리 전체 대조표는 JWT invalid signature 문제 해결 가이드에 있습니다. AES에 한정한 요약은 한 줄입니다. 키 자료가 어떤 인코딩인지 적어 두고, 양쪽 모두 명시적으로 디코딩하게 만드십시오. 같은 버그의 HMAC 판은 웹훅 수신 측을 물어뜯는데, 그 이야기는 웹훅 서명 검증 가이드에 있습니다.
AES는 엄격합니다: 정확히 16, 24, 32바이트
대부분의 개발자가 먼저 만나는 원시 함수와 AES가 갈라지는 지점입니다. HMAC은 키 길이를 가리지 않습니다. RFC 2104는 블록 크기보다 긴 것은 해시하고 짧은 것은 0으로 채우므로, HMAC 생성기는 7바이트짜리 비밀 키든 700바이트짜리든 군말 없이 받습니다. AES는 적법한 키 길이가 정확히 세 개뿐이고, 나머지는 블록 하나를 처리하기도 전에 거절합니다.
이 엄격함은 오히려 도움이 됩니다. 길이 오류는 패딩 뒤에 숨지 않고 스스로 원인을 밝히는 유일한 AES 실패이기 때문입니다. 저희 도구는 이를 Key must be 16, 24, or 32 bytes (AES-128/192/256).라고 표현합니다. 길이가 어긋나는 경로는 대개 셋 중 하나입니다:
- 줄바꿈 하나가 딸려 옵니다.
KEY=$(cat key.txt)나echo "$KEY"가 대표적인 경로입니다.printf와echo -n을 사용하세요. 시크릿 관리 UI에서 복사할 때 붙어 오는 공백 하나도 똑같은 결과를 냅니다. - 디버거에서 복사한 값에
0x접두사가 남아 있습니다. 34자가 되면서 더 이상 유효한 hex가 아닙니다. - ASCII 밖의 문자가 섞여 있습니다.
contraseña는 10자이지만 UTF-8로는 11바이트이므로, 악센트가 하나 들어간 “32자” 패스프레이즈는 실제로는 33바이트입니다.
SecretKeySpec과 플랫폼 기본 문자셋
Java에는 배포한 뒤에야 모습을 드러내는 변종이 있습니다. 인자 없이 호출한 "my secret".getBytes()는 플랫폼 기본 문자셋을 쓰는데, JDK 18 이전에는 이 값이 file.encoding 속성에서, 결국 그 머신의 OS와 로케일에서 왔습니다. UTF-8로 돌아가는 노트북과 ANSI_X3.4-1968로 돌아가는 컨테이너는 ASCII 밖의 문자에 대해 서로 다른 바이트를 내놓습니다. JEP 400이 JDK 18부터 UTF-8을 기본값으로 만들었지만, 그것이 고쳐 주는 것은 새로 쓰는 코드뿐입니다.
// 잘못된 방식: 바이트가 머신에 따라 달라집니다
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");
// 올바른 방식: 바이트가 무엇에도 좌우되지 않습니다
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");
로컬에서는 되는데 서버에서 패딩 오류로 실패하고 패스프레이즈에 ASCII 밖의 문자가 하나라도 들어 있다면, 이것부터 확인하세요.
4. IV: 어디에 두는가, 틀리면 어떻게 보이는가
aes iv mismatch는 사람들이 가장 먼저 의심하고 가장 늦게 진단하는 실패입니다. 다른 실패들과 달리 조용하고 국소적으로 나타나기 때문입니다.
잘못된 IV는 정확히 한 블록만 망가뜨립니다
키는 맞고 IV가 1바이트 틀렸던 시나리오 B의 결과입니다:
hello world -> iello world
예외도 없고 어긋난 것은 한 글자뿐입니다. 첫 블록에 대한 CBC 계산식을 적어 보면 분명해집니다. P1 = D(C1) XOR IV입니다. IV는 첫 평문 블록에 곧바로 XOR될 뿐 그 밖의 어디에도 닿지 않으므로, IV의 비트 하나를 뒤집으면 같은 위치의 평문 비트 하나가 뒤집힙니다. 여기서는 h(0x68)가 i(0x69)가 됐으니, IV의 첫 바이트가 정확히 0x01만큼 움직인 것입니다.
이것이 지문입니다. CBC에서 첫 16바이트가 쓰레기이고 그 뒤가 멀쩡하면 IV가 틀렸고 키는 맞습니다. 모든 블록이 쓰레기이면 키가 틀렸습니다. 이 관찰 하나로 가장 흔한 두 원인이 코드 한 줄 고치지 않고 갈라지며, AES 복호화 도구는 디코딩된 바이트를 보여 주므로 그 자리에서 바로 읽을 수 있습니다.
아무것도 던지지 않은 이유는 패딩 영역이 무사했기 때문입니다. hello world는 11바이트라서 블록 하나에 들어가고, PKCS#7 패딩은 그 블록의 11번부터 15번 바이트에 자리 잡습니다. 바뀐 IV 바이트는 0번이었으므로 패딩 영역은 손대지 않은 채 검사를 통과했습니다. IV의 11번 이후 바이트를 망가뜨리면 대신 패딩 오류가 나는데, 이것 또한 패딩 오류가 거짓말하는 경로 중 하나입니다.
세 가지 전송 관례
IV를 어디에 둘지에 대한 표준은 없습니다. 서로 잘 맞지 않는 관습이 세 가지 있을 뿐입니다.
앞에 붙입니다. iv || ciphertext 형태로, 가장 흔한 관례이며 저희 도구의 기본값이기도 합니다. 양쪽이 몇 바이트를 떼어 낼지 합의해야 합니다. CBC와 CTR은 16바이트, GCM은 12바이트입니다. 이와 짝을 이루는 버그는 생산 측은 앞에 붙였는데 소비 측은 떼어 내지 않는 경우입니다. 그러면 “암호문”의 첫 16바이트가 실은 IV이고, 모든 블록이 밀려나면서 패딩 오류가 납니다.
별도 필드에 담습니다. {"iv": "...", "ciphertext": "..."}는 원리상 더 깔끔하지만, 인코딩이 어긋날 수 있는 자리를 두 배로 늘립니다. 이제 IV에도 Base64냐 hex냐 하는 질문이 따로 생기기 때문입니다.
고정 상수를 씁니다. 대개 전부 0이고, 누군가 결정론적인 결과가 필요해서 하드코딩한 것입니다. 상호 운용은 완벽하게 되는데, 바로 그 점이 이 방식을 위험하게 만듭니다. CBC에서 고정 IV는 레코드끼리 같은 값인지를 새어 나가게 하고, GCM에서 한 키 아래 논스를 재사용하면 두 평문의 XOR이 드러날 뿐 아니라 태그를 인증하는 GHASH 서브키까지 노출될 수 있습니다. SP 800-38D는 유일성을 명시적으로 요구합니다.
도구의 순수 암호문 스위치에 IV 직접 지정을 더하면, 같은 바이트를 놓고 세 관례를 차례로 시험해 볼 수 있습니다.
GCM의 IV는 16바이트가 아니라 12바이트입니다
기존 CBC 경로를 손봐서 GCM을 도입한 팀은 16바이트 IV를 그대로 들고 오고, 그 결과는 아무 단서도 없이 실패합니다.
SP 800-38D는 96비트 IV를 표준으로 정합니다. 다른 길이도 허용되지만 그것은 단순히 “더 긴 IV”가 아닙니다. IV가 96비트가 아니면 GCM은 IV를 그대로 쓰지 않고 GHASH에 통과시켜 초기 카운터 블록을 파생합니다. 따라서 같은 16바이트를 논스로 쓰면 앞의 12바이트만 썼을 때와는 완전히 다른 키스트림과 태그가 나오고, 손에는 아무것도 특정해 주지 않는 인증 실패만 남습니다. 암호문이 외부에서 왔고 배치를 추측해야 하는 상황이라면 뒤에서부터 세십시오. 태그는 마지막 16바이트이고, 논스는 거의 언제나 앞의 12바이트입니다.
5. 모드 불일치, 소리 없는 종류까지
Cipher.getInstance("AES")는 ECB입니다
Java에서는 모드도 패딩 방식도 적지 않고 암호 이름만 적어도 됩니다. 거부하지도 않고 경고하지도 않습니다. JDK 내장 SunJCE 프로바이더에서는 빈칸이 ECB와 PKCS5Padding으로 채워집니다.
이를 증명하려면 실험을 제대로 설계해야 합니다. 똑같은 바이트 32개(A로 채운 두 블록)를 키 0123456789abcdef로 암호화한 뒤, 두 암호문 블록이 일치하는지 확인하는 것입니다. java 1.8.0_162 기준입니다:
getInstance("AES") ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
block1 = 3bfd04cc0d7ed55358e2cbe19de21383
block2 = 3bfd04cc0d7ed55358e2cbe19de21383 <- 두 블록이 동일 = ECB의 지문 (평문 구조가 새어 나감)
getInstance("AES/CBC/PKCS5Padding") 두 블록이 다름 = 체이닝이 작동 중
바이트 단위로 완전히 같습니다. 이것이 ECB의 특징이며, 그 유명한 “암호화된 펭귄” 이미지가 여전히 펭귄으로 보이게 만드는 바로 그 성질입니다. 이 실험은 평문 블록이 서로 같을 때만 통합니다. A 16바이트 뒤에 B 16바이트를 붙이면 ECB에서도 암호문 블록 두 개가 서로 다르게 나오므로, 기본값이 CBC라고 잘못 결론 내리게 됩니다.
결과의 적용 범위는 조심해서 잡으십시오. 위 버전의 JDK 내장 SunJCE 프로바이더에 대한 이야기입니다. 기본 변환 문자열은 프로바이더가 결정하는 사항이므로, BouncyCastle 같은 서드파티 프로바이더는 같은 축약형을 다르게 해석할 수 있습니다. 일반화해야 할 문장은 “Java는 곧 ECB다”가 아니라 “한정하지 않은 변환 문자열은 프로바이더가 정하는 무언가를 뜻하고, 그래서 그런 문자열은 절대 쓰지 않는다”입니다.
모드가 틀려도 오류가 나지 않을 수 있습니다
시나리오 C는 CBC 암호문을 AES/ECB로 복호화했는데 예외 없이 올바른 평문을 내놓았습니다. 계산식을 적어 보기 전까지는 불가능해 보입니다. 첫 블록에 대한 CBC 암호화는 C1 = E(P1 XOR IV)이고, 그 블록에 대한 ECB 복호화는 D(C1) = P1 XOR IV입니다. 여기서 IV는 전부 0이었으므로 P1 XOR 0 = P1이 되어 첫 블록이 완벽하게 복호화됩니다. hello world는 블록 하나 길이라서, “첫 블록”이 곧 메시지 전체였습니다.
여기서 기억해 둘 만한 일반 규칙이 하나 나옵니다. IV가 0이면 ECB와 CBC는 첫 블록에서 일치하고 그 뒤의 모든 블록에서 어긋납니다. 긴 CBC 메시지를 ECB로 복호화하면 멀쩡한 16바이트 뒤에 잡음이 이어지는데, 이는 잘못된 IV의 지문을 정확히 뒤집어 놓은 모양입니다. 모양은 정반대이고 버그도 서로 다르지만, 어느 쪽에서도 오류 메시지는 나오지 않습니다. 0으로 하드코딩된 IV는 실험실에서나 보는 진기한 물건이라 하기에는 너무 흔합니다.
언어별로 최소 호출이 주는 것
| 생태계 | 최소 호출 | 실제로 얻는 모드 |
|---|---|---|
| Java (SunJCE) | Cipher.getInstance("AES") | 조용히 ECB에 PKCS5Padding |
Node crypto | createDecipheriv('aes-256-cbc', key, iv) | 알고리즘 문자열이 말하는 그대로. 기본값이 존재하지 않음 |
| Web Crypto | crypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...) | 명시적으로 지정. ECB는 아예 구현되어 있지 않음 |
Python cryptography | Cipher(algorithms.AES(key), modes.CBC(iv)) | 모드 객체가 필수 |
| PyCryptodome | AES.new(key, AES.MODE_ECB) | 인자가 필수이지만, ECB가 자동 완성 목록에 바로 보임 |
Go crypto/aes | aes.NewCipher(key)는 원시 cipher.Block을 내놓음 | 그 블록에 Decrypt를 호출하면 그것이 곧 ECB. cipher.NewCBCDecrypter나 cipher.NewGCM으로 감싸야 함 |
| CryptoJS | CryptoJS.AES.decrypt(ct, "passphrase") | CBC, PKCS#7, MD5 기반 EVP_BytesToKey(7절 참고) |
모드가 문자열이나 객체 안에 들어 있는 생태계는 사람을 놀라게 하지 않습니다. “그냥 AES” 호출이 있는 두 곳, Java와 Go가 의도치 않은 ECB 제보가 올라오는 진원지입니다. 한국 현장에서는 SEED, ARIA, LEA 같은 국산 블록 암호가 AES와 한 코드베이스 안에 같이 들어가 있는 일이 흔한데, 어느 알고리즘을 쓰든 양쪽이 모드와 패딩을 맞춰야 한다는 점은 그대로입니다. 어느 모드인지 확신이 서지 않으면 같은 바이트를 놓고 모드만 바꿔 가며 복호화를 시험해 보십시오.
6. GCM: 같은 바이트, 다른 API
언어를 넘나드는 aes gcm auth tag 실패는 대부분 암호학적 문제가 아닙니다. 양쪽 모두 똑같은 16바이트를 계산해 놓고, 그 바이트가 어디에 있느냐를 두고 어긋난 것입니다.
실측값
키는 32바이트 0123456789abcdef0123456789abcdef, IV는 0으로 채운 12바이트, 평문은 hello world, 실행 환경은 node v25.8.2와 java 1.8.0_162입니다:
Node ciphertext = a616cd6d7d2328379d41e5 (11 B) <- update+final
authTag = c87af9f8ad7148e873fa797292c0af3f (16 B) <- getAuthTag()로 따로 가져옴
Java doFinal() = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f (27 B) <- 암호문과 태그가 이미 이어 붙어 있음
Node ciphertext || authTag는 27바이트 전부가 Java doFinal()과 정확히 같습니다. 바이트를 변환하거나 맞춰 줄 일은 없습니다. Node는 두 조각을 따로 건네주고, Java는 붙여서 건네줄 뿐입니다. 평문 11바이트가 암호문 11바이트가 됐다는 점도 눈여겨보십시오. GCM은 패딩을 붙이지 않기 때문입니다. 진짜 GCM 경로에서는 패딩 오류가 절대 나올 수 없는 이유가 여기 있습니다.
런타임별로 이어 붙이는가, 떼어 놓는가
| 런타임 | 암호화 API | 태그가 놓이는 자리 |
|---|---|---|
Node crypto | update() + final() 후 getAuthTag() | 분리 |
Java (SunJCE, AES/GCM/NoPadding) | doFinal() | 뒤에 붙음 |
Go cipher.AEAD | Seal() | 뒤에 붙음 |
Python cryptography, AESGCM | encrypt() | 뒤에 붙음 |
Python cryptography, Cipher + modes.GCM | finalize() 후 encryptor.tag | 분리 |
| Web Crypto | crypto.subtle.encrypt | 뒤에 붙음 |
고수준 API 중에서는 Node만 혼자 다릅니다. Node에서 다른 무엇으로 넘어가는 방향의 실패가 가장 많이 보고되는 이유입니다. 손에 든 덩어리가 어느 배치인지 모르겠다면 그 바이트를 그대로 넣어 태그 위치를 대조해 보십시오. Java, Go, Python 또는 브라우저 쪽 소비자를 위해 Node 출력을 포장하는 코드는 이렇습니다:
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const packed = Buffer.concat([ct, cipher.getAuthTag()]); // 이제 doFinal()과 일치합니다
반대 방향, 곧 이어 붙은 덩어리를 Node에서 풀어낼 때는 다음과 같습니다:
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16)); // final()보다 먼저 호출해야 합니다
const pt = Buffer.concat([
decipher.update(packed.subarray(0, packed.length - 16)),
decipher.final(),
]);
이 호출 순서는 반드시 지켜야 합니다. setAuthTag()를 final() 뒤에 호출하면, 모든 바이트가 정확해도 Node는 Unsupported state or unable to authenticate data를 던집니다.
태그 길이는 고를 수 있지만, 단위는 고를 수 없습니다
GCM은 128, 120, 112, 104, 96비트 태그를 허용하고, 64비트와 32비트는 자원이 제한된 응용을 위해 남겨 두었습니다(SP 800-38D 부록 C). 거의 모두가 128을 쓰는데, 문제는 각 API가 그 값을 요구하는 방식입니다:
- Java:
new GCMParameterSpec(128, iv). 첫 번째 인자의 단위는 비트입니다. - Web Crypto:
{ name: 'AES-GCM', iv, tagLength: 128 }. 여기도 비트이고 기본값은 128입니다. - Node:
createCipheriv(algo, key, iv, { authTagLength: 16 }). 단위는 바이트입니다.
new GCMParameterSpec(16, iv)은 적법해 보이는 Java 코드 한 줄이지만 16비트짜리 태그를 요구합니다. 어떤 JDK는 이를 거절하고, 받아 주는 곳에서는 무결성 보장을 65,536분의 1짜리 동전 던지기와 맞바꾼 셈이 됩니다. 양쪽이 태그 길이에 합의하지 못하면 포장된 전체 길이도 달라지므로, 수신 측은 엉뚱한 경계에서 잘라 내고 키와는 아무 상관도 없는 인증 실패를 받게 됩니다.
7. 여러분이 가진 것은 키가 아니라 패스프레이즈입니다
어느 한쪽이라도 사람이 입력한 문자열을 받는다면, 그 문자열과 AES 사이에는 키 파생 함수가 하나 끼어 있고, KDF 불일치는 눈에 보이지 않습니다. 오류를 내는 법이 없습니다. 흠잡을 데 없는 32바이트를 내놓는데 그게 하필 틀린 32바이트일 뿐이고, 실패는 한 층 아래에서 (이제 아시겠지만) 패딩 오류의 모습으로 드러납니다.
PBKDF2는 네 가지가 맞아떨어져야 합니다
- 솔트. OpenSSL의
Salted__형식에서는 암호문 안에 든 8바이트이고, 저희 도구의 패스프레이즈 형식에서는 16바이트 접두사이며, 직접 만든 방식에서는 하드코딩된 상수인 경우가 잦습니다. - 반복 횟수.
openssl enc -pbkdf2의 기본값은 10,000입니다. OWASP는 현재 PBKDF2-HMAC-SHA256에 600,000을 권장하며, 저희 패스프레이즈 모드가 쓰는 값이 그것입니다. 프레임워크는 저마다 자기 숫자를 고릅니다. - 해시. SHA-1이냐 SHA-256이냐 SHA-512냐입니다. 오래된 코드와 일부 모바일 SDK는 여전히 SHA-1을 기본값으로 씁니다.
- 출력 길이. AES-256은 32바이트, AES-128은 16바이트입니다. 어떤 방식은 한 번의 긴 호출로 키와 IV를 함께 파생하는데, 이는 순수한 32바이트 파생과 절대 일치하지 않습니다.
EVP_BytesToKey, 그리고 CryptoJS가 자꾸 안 되는 이유
cryptojs aes decrypt not working은 대개 특정한 불일치 하나를 가리킵니다. CryptoJS.AES.encrypt(text, "passphrase")는 PBKDF2를 쓰지 않습니다. OpenSSL 1.1 이전의 파생 방식인 EVP_BytesToKey를, MD5와 단 한 번의 반복으로 씁니다.
EVP_BytesToKey는 PBKDF2가 하지 않는 일도 합니다. 패스프레이즈와 솔트에서 키와 IV를 한 번에 함께 파생합니다. OpenSSL Salted__ 파일에 별도의 IV 필드가 없는 이유이자, PBKDF2에 무작위 IV를 얹어 CryptoJS 출력을 재현하려는 시도가 두 겹으로 틀린 이유입니다.
이 형식은 눈으로 알아볼 수 있습니다. ASCII 8바이트 Salted__ 뒤에 8바이트 솔트가 붙은 형태를 Base64로 인코딩하면 언제나 U2FsdGVkX1로 시작합니다. 암호문이 이렇게 시작한다면 패스프레이즈에서 파생된 것이니 어느 파생 방식인지 알아내야 합니다. AES 복호화 도구는 이 접두사를 감지해 코드를 고치지 않고도 세 방식을 오갑니다.
같은 비밀번호에서 서로 다른 키가 나오는 이유
“AES 비밀번호”라는 것은 존재하지 않습니다. 라이브러리마다 문자열에서 키로 가는 길을 제 나름대로 발명했습니다:
| 생산자 | 파생 방식 | 하나의 패스프레이즈에서 나오는 결과 |
|---|---|---|
CryptoJS AES.encrypt(text, pass) | EVP_BytesToKey, MD5, 1회 반복 | 키 A |
openssl enc 1.0.2 이하 | EVP_BytesToKey, MD5, 1회 반복 | 키 A |
openssl enc 1.1 이상, -pbkdf2 없이 | EVP_BytesToKey, SHA-256, 1회 반복 | 키 B |
openssl enc -pbkdf2 | PBKDF2-HMAC-SHA256, 10,000회 반복 | 키 C |
| 저희 패스프레이즈 모드 | PBKDF2-HMAC-SHA256, 600,000회 반복 | 키 D |
| Java, Python, Go | 기본값 자체가 없음. 파생을 직접 작성 | 작성한 그대로 |
아무도 실수를 저지르기 전에, 비밀번호 하나에서 키 네 개가 나옵니다. 1.0.2에서 1.1로 올라가면서 기본 다이제스트가 MD5에서 SHA-256으로 바뀌었고, 그래서 예전 스크립트가 만든 암호문이 새 장비에서 같은 명령으로는 더 이상 복호화되지 않습니다. 물려받은 데이터인데 아무도 툴체인을 기억하지 못한다면, 위 표의 순서대로 파생 방식을 시도해 보십시오. 탐색이 아니라 세 갈래 시험일 뿐입니다.
8. 전송 구간이 암호문에 한 일
암호문은 고르게 무작위인 이진 데이터라서, 바이트를 텍스트로 취급하는 도구와는 하나같이 상극입니다. AES 실패의 상당 부분은 암호 알고리즘 근처에도 가지 않습니다.
Base64 변종과 빠진 패딩
표준 Base64(RFC 4648 §4)는 +와 /를 쓰고, URL 안전 변종(§5)은 -와 _를 씁니다. URL 안전 문자열을 표준 디코더에 넘기면 예외가 나거나, 관대한 디코더에서는 문제의 문자를 조용히 버리고 짧고 어긋난 바이트를 내놓습니다. Java가 Base64.getUrlDecoder()와 Base64.getDecoder()를 별개의 객체로 나눠 둔 이유입니다. 어떤 인코더는 끝의 =를 떼어 버리고 어떤 디코더는 그것을 반드시 요구하며, JWT 언저리의 코드 경로는 기본적으로 그것을 벗겨 냅니다.
키를 의심하기 전에 암호문을 디코딩하고 길이를 모드와 대조하십시오:
- CBC와 ECB: 0이 아닌 16의 배수입니다. 그 밖의 값은 키 문제가 아니라 잘림이거나 디코딩 문제입니다.
- GCM: 암호문 길이는 평문 길이와 같고, 태그 16바이트가 더해지며, 논스를 앞에 붙였다면 앞쪽에 12바이트가 더 붙습니다.
- CTR: 길이에 제약이 없으므로 이 검사로는 아무것도 알 수 없습니다.
Base64 디코더는 한 번 붙여 넣는 것만으로 바이트 수를 알려 주며, 조사 전체에서 가장 빠른 측정인 경우가 많습니다.
줄바꿈, 둥근 따옴표, 그리고 UTF-8 왕복
openssl base64는 -A를 주지 않으면 출력을 64열에서 줄바꿈하는데, 어떤 디코더는 중간에 낀 줄바꿈을 건너뛰고 어떤 디코더는 거부하므로 같은 파일이 이 머신에서는 디코딩되고 저 머신에서는 실패합니다. 채팅 클라이언트나 문서 편집기를 거쳐 복사하면 곧은 따옴표가 둥근 따옴표로, 하이픈이 엔 대시로 바뀌는데, 터미널에서는 그 차이가 거의 보이지 않습니다.
되돌릴 수 없는 것은 UTF-8 왕복입니다. AES의 원시 출력을 먼저 인코딩하지 않고 문자열로 담는 순간(Java의 new String(cipherBytes), Python의 bytes.decode('utf-8', errors='replace'), 어디에서든 TextDecoder), 유효한 UTF-8이 아닌 모든 바이트 시퀀스가 U+FFFD로 뭉개지고, 그것을 다시 인코딩하면 데이터가 있던 자리에 EF BF BD가 들어앉습니다. 무작위 바이트의 절반가량은 ASCII 밖이므로 암호문 대부분이 파괴되고 어떤 키로도 복구되지 않습니다. 이 손실이 왜 단방향인지는 UTF-8과 UTF-16 인코딩 가이드에서 다룹니다. 이진 암호문은 Base64나 hex로 옮기거나, 이진 그대로 옮기십시오. 문자열로는 절대 옮기지 마십시오.
데이터베이스 컬럼
저장소는 같은 피해를 더 조용히 입힙니다. 한 블록만큼 긴 암호문을 VARCHAR(255)에 쓰면 잘려 나가고, strict 모드가 아닌 MySQL은 그 일을 오류 없이 해냅니다. 잘려 나가는 꼬리 부분이 바로 패딩 블록과 GCM 태그가 사는 곳이므로, 몇 달 전에 “성공적으로” 기록된 행이 이제 와서 실패합니다. 게다가 잘린 지점이 16바이트 경계에 맞아떨어졌다면 앞서 말한 길이 검사로도 잡히지 않습니다. 문자셋 변환이 남은 부분을 마저 망가뜨립니다. UTF-8 바이트를 받은 latin1 컬럼은 들어오는 길에 데이터를 새로 써 버립니다.
암호문은 VARBINARY, BLOB, bytea에 저장하거나, 여유 있는 크기의 텍스트 컬럼에 Base64로 저장하세요.
9. 5분 안에 원인을 찾아내는 이분 탐색 절차
위의 모든 절은 변수를 하나씩 좁힙니다. 여러분이 통제하는 기준 구현을 놓고 순서대로 돌리면 빠르게 수렴하는데, 설정을 하나씩만 바꿔 가며 바이트를 눈으로 볼 수 있고 모든 처리가 브라우저 안에서 끝나 키와 암호문이 페이지를 벗어나지 않는다는 점에서 브라우저 도구가 그 기준 노릇을 잘합니다.
-
0단계: 모양을 측정합니다. 암호문을 디코딩해 바이트 수, 앞쪽 몇 바이트, 그리고
U2FsdGVkX1로 시작하는지를 적어 두십시오. 바이트 수를 8절과 대조하세요. 16의 배수가 아닌데 CBC라고 믿고 있다면 거기서 멈추십시오. 이것은 전송 버그입니다. -
1단계: 알고 있는 평문을 암호화합니다. AES 암호화 도구에서 운영 환경이 쓴다고 믿고 있는 파라미터로 짧고 아는 문자열을 암호화한 다음, 두 출력의 값이 아니라 모양을 비교하십시오. 전체 길이, 앞쪽 바이트, 솔트 헤더의 유무입니다. 여기서 어긋난다면 형식이나 KDF에 대한 가정이 틀린 것이고, 키를 아무리 만져도 해결되지 않습니다.
-
2단계: 파생 방식을 차례로 돌립니다. 패스프레이즈에서 파생된 데이터라면 AES 복호화 도구에서 정확한 반복 횟수를 넣은 PBKDF2, 그다음 EVP-SHA256, 그다음 EVP-MD5를 시도하세요. 맞을 수 있는 것은 정확히 하나뿐입니다. 어느 것도 통하지 않으면 버그는 KDF보다 위에 있습니다.
-
3단계: 모든 관례를 걷어냅니다. 원시 키로 바꾸고, 순수 암호문 모드를 켜고, IV를 직접 넣으십시오. 이제 어떤 바이트가 키이고 IV이고 암호문인지를 하나도 추론에 맡기지 않고 명시한 상태입니다. 여기서는 복호화되는데 여러분의 코드에서는 안 된다면, 버그는 암호학적인 것이 아니라 포장 방식의 문제(떼어 내지 않은 IV 접두사, 엉뚱한 자리에 놓인 태그)입니다.
-
4단계: 모드를 바꿔 봅니다. 같은 바이트를 놓고 CBC, CTR, GCM을 차례로 시도하세요. CBC가 실패한 자리에서 CTR이 읽을 수 있는 텍스트를 내놓는다면 그것은 모드 불일치이고, 달리 해석할 여지가 없습니다.
-
5단계: 쓰레기를 읽습니다. 첫 블록이 망가지고 나머지가 멀쩡하면 IV입니다. 첫 블록이 멀쩡하고 나머지가 망가졌다면 0인 IV로 만든 CBC 암호문을 ECB로 복호화한 것입니다. 전부 망가졌다면 키이거나 파생 방식입니다.
10. 자주 묻는 질문
AES 코드가 로컬에서는 되는데 운영 환경에서는 실패하는 이유는 무엇입니까?
로컬과 운영 환경을 가르는 것은 소스 관리에 들어 있지 않은 무언가입니다. 가능성이 높은 후보는 넷입니다. 환경 변수나 시크릿 관리자에서 온 키에 줄바꿈이 붙어 있었거나, Java의 플랫폼 기본 문자셋이 노트북과 컨테이너에서 달라 getBytes()가 서로 다른 바이트를 내놓았거나(3절), 로컬 스크립트는 1.0.2를 겨냥했는데 운영 환경의 OpenSSL은 1.1 이상이라 EVP_BytesToKey의 다이제스트가 MD5에서 SHA-256으로 바뀌었거나, 한쪽 환경에서만 데이터베이스 컬럼이 암호문을 잘라 냈을 수 있습니다. 먼저 양쪽에서 키 길이와 암호문 길이를 바이트 단위로 찍어 보십시오. 이 두 숫자면 대개 결판이 납니다.
Node에서 암호화했는데 Java에서 복호화되지 않습니다. 어디부터 봐야 합니까?
Node와 Java 사이에서 가장 흔하면서 가장 눈에 띄지 않는 원인인 GCM 태그부터 보십시오. Node는 암호문과 태그를 따로 내놓고, Java의 doFinal()은 둘이 ciphertext || tag 순서로 이어 붙어 있기를 기대합니다. 그 밖의 바이트는 동일하다는 것을 6절에서 확인할 수 있습니다. CBC라면 IV 관례부터 보십시오. Node가 IV를 앞에 붙였는지, Java 쪽이 복호화 전에 16바이트를 떼어 내는지 확인하세요. 세 번째는 키 자체입니다. Buffer.from(k, 'hex')와 k.getBytes(StandardCharsets.UTF_8)은 같은 문자열에서 서로 다른 길이를 만들어 냅니다.
Java의 PKCS5Padding은 PKCS#7과 같습니까?
AES에서는 PKCS5Padding과 PKCS#7이 사실상 같습니다. PKCS#5(RFC 8018)는 8바이트 블록에 대해서만 정의되어 있고, PKCS#7(RFC 5652)이 그 방식을 1바이트부터 255바이트까지의 블록 크기로 일반화했습니다. 16바이트 블록 암호에 적용된 Java의 PKCS5Padding은 PKCS#7 동작을 구현한 것이고 이름은 역사가 남긴 흔적일 뿐이므로, 이것이 원인인 경우는 없습니다. NoPadding은 다릅니다. 평문이 이미 16의 배수여야 하고, 복호화할 때는 패딩을 데이터로 그대로 넘겨주므로 \x05\x05\x05\x05\x05 같은 꼬리가 붙은 그럴듯한 텍스트를 보게 됩니다.
키가 32자인데 AES는 키 길이가 유효하지 않다고 합니다. 왜 그렇습니까?
길이 오류는 라이브러리가 받은 바이트 수가 16도 24도 32도 아니라는 뜻입니다. 32자 문자열이라면 대개 줄바꿈이 딸려 왔거나(33바이트), 0x 접두사 때문에 유효한 hex가 아니게 됐거나, ASCII 밖의 문자 하나가 UTF-8에서 2~3바이트를 차지한 경우입니다. 더 위험한 변종은 오류가 아예 나지 않는 경우입니다. hex 32자는 유효한 16바이트로, Base64 32자는 유효한 24바이트로 디코딩되며 둘 다 적법한 AES 길이입니다. 라이브러리는 그것을 받아들여 틀린 키를 쓰고, 여러분에게는 패딩 실패를 건네줍니다. 문자 수가 아니라 바이트 수를 확인하세요.
복호화는 “성공”했는데 출력이 쓰레기입니다. 무엇이 잘못됐습니까?
복호화가 “성공”하고도 출력이 쓰레기라면, 아무것도 검증하지 않는 모드에 있는 것입니다. CTR과 ECB는 절대 예외를 던지지 않고, CBC는 마지막 바이트 패턴이 패딩 검사를 통과하지 못할 때만 던지는데 틀린 키도 0.4%가 조금 안 되는 확률로 그 검사를 통과합니다. 모양을 읽으십시오. 첫 16바이트가 망가지고 나머지가 멀쩡하면 IV입니다. 첫 16바이트가 멀쩡하고 나머지가 망가졌다면 0인 IV로 만든 CBC 암호문을 ECB로 복호화한 것입니다. 고르게 다 망가졌다면 키이거나 파생 방식입니다. 읽을 수 있는 텍스트 끝에 이상한 바이트 몇 개가 붙어 있다면 패딩된 데이터에 NoPadding을 쓴 것입니다. 장기적인 해법은 GCM으로 옮겨 “성공”이라는 말에 의미가 생기게 하는 것입니다.
IV를 잃어버렸는데 복호화할 수 있습니까?
IV를 잃어버려도 CBC에서는 복호화됩니다. 첫 16바이트를 뺀 전부가 살아납니다. 2번 블록부터는 D(C_i) XOR C_{i-1}로 복원되고 그 계산에 필요한 입력은 모두 이미 암호문 안에 있으므로, IV가 필요한 것은 첫 블록뿐입니다. 평문이 어떻게 시작하는지까지 알고 있다면, 예를 들어 JSON 덩어리가 {"userId":로 시작한다면, D(C1) XOR P1로 IV 자체를 통째로 복원할 수 있습니다. CTR에서는 IV가 키스트림 전체의 씨앗이므로 잃어버리면 전부 잃습니다. GCM에서는 논스가 카운터와 태그 양쪽에 들어가므로 부분 복원조차 없습니다.
GCM 태그가 잘리거나 사라졌다면 평문을 복구할 수 있습니까?
태그가 잘렸거나 사라진 GCM 암호문에서 평문을 되살리는 일은 수학적으로는 가능하고, 실무적으로는 손이 좀 갑니다. GCM은 속을 들여다보면 CTR 모드이므로 키와 논스만 있으면 키스트림을 재현할 수 있습니다. 다만 주류 라이브러리 중에 이를 대신해 주는 것은 없습니다. Java, Go, Python, Web Crypto 모두 유효한 태그 없이는 평문을 내놓지 않도록 설계되어 있습니다. 우회 방법은 같은 바이트를 AES-CTR로 복호화하되, 초기 카운터 블록을 12바이트 논스 뒤에 00000002를 붙인 값으로 지정하는 것입니다. GCM의 첫 데이터 블록이 시작하는 지점이 바로 거기입니다. 데이터는 되찾지만 무결성 보장은 전부 포기하는 것이니, 그 결과는 신뢰할 수 없는 값으로 다루십시오. 태그 16바이트가 전부 있는데도 인증이 실패한다면 태그가 없어서가 아니고, 이 문서의 다른 무언가가 여러분의 버그입니다. AES 복호화 도구로 가져가서 0단계부터 시작하십시오.