1. 문제 정의
Node.js에서 crypto-js 패키지로 AES 암호화한 데이터를 Go에서 복호화하려고 하면, 값이 깨지거나 panic이 발생한다. 아래는 문제를 일으킨 Node.js 암호화 코드다.
| |
출력 예:
| |
그리고 아래처럼 문자열 key를 그대로 aes.NewCipher에 넘겨 CBC로 복호화한 Go 코드가 panic 및 깨진 값을 만든다.
| |
2. 원인 탐구 — 표면 증상을 나누어 본다
처음 보기에는 Go 코드가 키를 잘못 쓰는 것 같다. 실제로 맞는 관찰이다. 하지만 원인은 Go 코드 한 줄 문제가 아니라, Node.js 쪽 CryptoJS의 문자열 키 처리 방식에 숨어 있다. 관찰할 수 있는 증상을 정리하자.
| 표면 증상 | 관찰 지점 | 즉시 드는 가설 | 실제 요인 |
|---|---|---|---|
| Go 복호화 결과가 깨진 바이트 | fmt.Println(plaintext) | IV가 틀렸다 | 파생된 키/IV와 다르다는 전제를 Go가 재현하지 못함 |
| Go가 panic 발생 | aes.NewCipher 에 16바이트가 아닌 길이의 key | key 길이 불일치 | 문자열 32자를 그대로 raw key로 해석하면 길이 규약이 깨짐, CBC/CTR 블록 규약 위반 |
Node에서 전달된 iv가 복호화에 무시되는 느낌 | 복호화 실패 | IV 불일치 | 실제로 CryptoJS는 그 IV를 완전히 무시한다 |
3. 근본 원인 분석 — CryptoJS의 문자열은 key가 아니라 passphrase
CryptoJS의 crypto.AES.encrypt(message, key_or_passphrase, cfg) 에서 두 번째 인자가 문자열이면 그 값은 키(raw key)가 아니라 패스프레이즈(passphrase) 로 해석된다.
그 순간 다음이 벌어진다.
- 암호화할 때 8바이트 임의 salt 를 생성한다.
- 이 salt와 passphrase로 KDF
EVP_BytesToKey()를 통해 키와 IV를 파생한다. - 코드에서 명시적으로 넘긴
createRandomIv()의 IV는 무시된다. (문제 코드에서iv를 만들고 넘겼지만, 문자열 key를 썼으니 그 IV는 쓰이지 않는다.) hash.toString()의 결과는 OpenSSL 포맷 — 접두어Salted__+ salt + 실제 ciphertext (Base64).eHex는 이를 Hex로 인코딩한 것에 불과하다.- 스트림 모드 CTR이라도 CryptoJS는 자동으로 padding을 끄지 않는다. 따라서 PKCS#7 패딩이 붙어 있다.
Go 쪽이 문제인 것은 분명하지만, 정확히는 “Go가 이 EVP_BytesToKey 기반 파생 파이프라인을 동일하게 재현하지 못한 것"이 근본 원인이다.
검증 요지 (답변 원문 중심): “In the CryptoJS code, the second parameter in crypto.AES.encrypt() is passed as a string, so it is interpreted as passphrase. … The IV derived with createRandomIv() and explicitly passed in crypto.AES.encrypt() is ignored! … hash.ToString() returns the result in OpenSSL format consisting of the prefix Salted__ followed by the salt and by the actual ciphertext.”
(참고) 문자 key가 아닌 WordArray를 넘겼다고 가정하자
만약 두 번째 인자로 WordArray(실제 key)를 넘겼다면, CryptoJS는 이를 raw key로 해석해 salt·EVP_BytesToKey 파이프라인을 거치지 않는다. 그 경우에는 salt/Salted__ 접두어가 생기지 않아 단순한 key/IV 정합으로 복호화가 된다. 그렇기 때문에 Node 쪽 코드가 “문자열을 쓰고 있다"는 사실 자체가 첫 관찰점이다.
4. 코드 해결책 — Go에서 OpenSSL 파이프라인 복원
아래 Go 코드는 답변에서 제안한 순서대로 동작한다.
:로 분리해split[1](Hex ciphertext)을 바이트로 디코딩한다.saltCiphertext[8:16]에서 salt를,[16:]에서 실제 ciphertext를 얻는다. (Saltd접두어가 8바이트이고 다음 8바이트가 salt다)github.com/walkert/go-evp의evp.BytesToKeyAES256CBCMD5(salt, password)로 키/IV를 재생산한다. (참고로 이 함수는 이름이 “AES256CBC"이지만, 반환되는 32바이트 key + 16바이트 IV를 CTR 모드에도 동일하게 사용할 수 있다.)- AES-CTR로 복호화하고,
PKCS7Unpad로 패딩을 제거한다.
| |
일반적인 특징: 위 코드에서
key, iv := evp.BytesToKeyAES256CBCMD5([]byte(salt), []byte("..."))의 동일한 파생 로직이 핵심이다. salt가 먼저 넘어가지 않는다면 키/IV가 하나도 일치하지 않아cipher.NewCTR에서부터 값이 깨진다.
5. 검증 명령 및 확인 기준
검증은 단순하다. 복호화된 평문이 다시 원래 문자열과 일치하는지 확인하는 것.
| |
Go에서 복호화가 되지 않고 plaintext가 깨지거나, XORKeyStream 후 길이가 잘못되면:
- salt 분리 위치가 잘못되지 않았는지 (
[8:16],[16:]) 확인 - 문자열 key를 그대로
aes.NewCipher에 넘기지 않았는지 확인 - padding 제거 함수가 항상 마지막 바이트를 신뢰하는지 확인한다. (가드 로직 부재 시 잘못된 패딩 바이트이면 인덱스 음수로 panic 가능)
6. 더 안전한 설계로 가는 방법 (장기 해결)
답변에서 강조하는 보안 관점도 인식하자.
EVP_BytesToKey()는 오늘날 안전하지 않은 것으로 간주된다. password→key 단방향 파생 알고리즘의 안전성 목적에 부합하지 않는다.- 권장 대안: 두 번째 문자열 대신 WordArray(정규 key) 를 넘겨서 CryptoJS가 raw key로 처리하도록 하고, 매 암호화마다 임의 IV를 사용한다.
- 필요하면 PBKDF2 같은 신뢰성 있는 KDF로 salt를 파생한다.
- CTR보다 GCM을 권장한다. ciphertext의 무결성(authenticity) 검증이 가능해지기 때문이다. GCM이 생성되는 tag를 키·IV·ciphertext와 함께 저장/운반하는 구조로 설계한다.
즉, 장기적으로는 “재사용 가능한 동일 복호화 계약"을 설계하고 Node 쪽과 Go 쪽이 같은 KDF(pbkdf2)와 인증 암호화(GCM)를 쓰도록 하는 것이 올바른 가성이다. 이 글의 코드는 기존에 생산된 데이터를 그대로 복호화하는 “레거시 호환” 해결책이다.
증상 fingerprint (확인용 정리)
- 에러 상황: Node.js CryptoJS로 암호화 → Go에서 AES 복호화 → panic / 깨진 문자열
- 발생 단계: ciphertext를 Go
aes.NewCipher에 직접 넘긴 뒤CryptBlocks/XORKeyStream시점 - 관련 도구: Node.js
crypto-js, Gocrypto/aes,github.com/wonp/go-evp - 흔한 오해: 문자열 key를 그대로 raw key로 쓰고 별도 IV를 만들어 주면 된다(사실 아님. 문자열은 passphrase가 되어 직접 IV는 무시, EVP 계약 필요)
- 빠른 판단 기준: Node.js
crypto.AES.encrypt(pass, stringKey, {iv, mode:CTR})형태라면 반드시 salt 기반 EVP_BytesToKey 파이프라인이 개입한다.
원인 후보 매트릭스
| 원인 후보 | 확인 명령 | 부합하는 증상 | 해결 방향 |
|---|---|---|---|
| Go에서 raw key 직접 사용 | aes.NewCipher([]byte("32자 문자열")) | NewCipher가 길이·블록 규율 위반으로 오류, panic | KDF로 유도한 key 사용 |
| CBC로 복호화 | cipher.NewCBCCDecrypter | 패딩/블록 크기 오류, 끝부분 깨짐 | CTR 모드 + PKCS7 unpad |
| salt 무시 | saltCiphertext[16:] 미분리 | ciphertext 시작이 Salted__ 8바이트 오염 | salt분리 후 BytesToKey |
| 암호화 시 무시된 IV를 당연히 씀 | 복호화에 split[0] IV 사용 | 값이 통째로 어긋남 | IV를 별도로 신뢰하지 말고 KDF의 IV 사용 |
DevTrace Verdict
이 문제의 핵심은 “Go가 AES key를 잘못 썼다"는 겉보기 증상이지, 실제로는 CryptoJS 문자열 인자 → passphrase → EVP_BytesToKey → Saltd__+salt+ciphertext 가 온전한 암호문 규약인데 Go쪽이 그 KDF 파이프라인을 재현을 못한 데 있다. 문자열이 아닌 WordArray key나 PBKDF2+GCM으로 바꾸면 이런 양쪽 규약 불일치 자체가 사라진다.
출처
- StackOverflow 원본 질문: Decrypt AES with Secret Key and IV From Node to Golang Panic
- 채택 답변 (저자 답변 기반 정리, 보안 대안 포함)
일반적인 점검 기준 및 검증 예시는 원본 출처 근거에 더해 환경(Go 버전,
github.com/walkert/go-evp라이브러리, Node/CryptoJS 버전)에 따라 다를 수 있으니 별도로 명시한 부분이다. 암호화 KDF 관련 결정은 항상 보안 플랫폼 권고를 우선하라.