1. 문제 정의

Node.js에서 crypto-js 패키지로 AES 암호화한 데이터를 Go에서 복호화하려고 하면, 값이 깨지거나 panic이 발생한다. 아래는 문제를 일으킨 Node.js 암호화 코드다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const crypto = require('crypto-js');

const cryptKey = 'b676eac8cf70442385dfd4bcfaa61b52';   // 32자리 문자열

const createRandomIv = function () {
    const keySize = 192 / 32;   // 6
    const ivSize = 128 / 32;    // 4
    const evp = crypto.algo.EvpKDF.create({ keySize: keySize + ivSize, hasher: crypto.algo.SHA1 }).compute(cryptKey);
    const iv = crypto.lib.WordArray.create(evp.words.slice(keySize), ivSize * 4);
    return iv.toString();
};

const encryptPassword = function (password) {
    const iv = createRandomIv();
    const hash = crypto.AES.encrypt(password, cryptKey, {
        iv,
        mode: crypto.mode.CTR
    });
    const base64 = crypto.enc.Base64.parse(hash.toString());
    const eHex = base64.toString(crypto.enc.Hex);
    return `${iv}:${eHex}`;
};

출력 예:

1
2db5c01aa26fa576e5e24d5e9d3c1e3a:53616c7465645f5f65e90f4b6f2f7444ebc4540ce7424efadc59f34e5f16f2d19a52d0c9ec9b9a1660a6af9a1c

그리고 아래처럼 문자열 key를 그대로 aes.NewCipher에 넘겨 CBC로 복호화한 Go 코드가 panic 및 깨진 값을 만든다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
    "crypto/aes"
    "crypto/cipher"
    "fmt"
    "strings"
)

func main() {
    secretKey := "b676eac8cf70442385dfd4bcfaa61b52"
    encryptedPwd := "2db5c01b4825b6d4d7a7b96f04f3bb5:53616c7465645f5ff691671363cda1b9d05ee6bdd637e1e99bc3b29ef2ad7ec53"
    split := strings.Split(encryptedPwd, ":")

    c, _ := aes.NewCipher([]byte(secretKey))
    cfbdec := cipher.NewCBCDecrypter(c, []byte(split[0]))
    plaintext := make([]byte, len(split[1]))
    cfbdec.CryptBlocks(plaintext, []byte(split[1]))
    fmt.Println(plaintext)
}

2. 원인 탐구 — 표면 증상을 나누어 본다

처음 보기에는 Go 코드가 키를 잘못 쓰는 것 같다. 실제로 맞는 관찰이다. 하지만 원인은 Go 코드 한 줄 문제가 아니라, Node.js 쪽 CryptoJS의 문자열 키 처리 방식에 숨어 있다. 관찰할 수 있는 증상을 정리하자.

표면 증상관찰 지점즉시 드는 가설실제 요인
Go 복호화 결과가 깨진 바이트fmt.Println(plaintext)IV가 틀렸다파생된 키/IV와 다르다는 전제를 Go가 재현하지 못함
Go가 panic 발생aes.NewCipher 에 16바이트가 아닌 길이의 keykey 길이 불일치문자열 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) 로 해석된다.

그 순간 다음이 벌어진다.

  1. 암호화할 때 8바이트 임의 salt 를 생성한다.
  2. 이 salt와 passphrase로 KDF EVP_BytesToKey() 를 통해 키와 IV를 파생한다.
  3. 코드에서 명시적으로 넘긴 createRandomIv()의 IV는 무시된다. (문제 코드에서 iv를 만들고 넘겼지만, 문자열 key를 썼으니 그 IV는 쓰이지 않는다.)
  4. hash.toString()의 결과는 OpenSSL 포맷 — 접두어 Salted__ + salt + 실제 ciphertext (Base64). eHex는 이를 Hex로 인코딩한 것에 불과하다.
  5. 스트림 모드 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 코드는 답변에서 제안한 순서대로 동작한다.

  1. :로 분리해 split[1] (Hex ciphertext)을 바이트로 디코딩한다.
  2. saltCiphertext[8:16]에서 salt를, [16:]에서 실제 ciphertext를 얻는다. (Saltd 접두어가 8바이트이고 다음 8바이트가 salt다)
  3. github.com/walkert/go-evpevp.BytesToKeyAES256CBCMD5(salt, password) 로 키/IV를 재생산한다. (참고로 이 함수는 이름이 “AES256CBC"이지만, 반환되는 32바이트 key + 16바이트 IV를 CTR 모드에도 동일하게 사용할 수 있다.)
  4. AES-CTR로 복호화하고, PKCS7Unpad로 패딩을 제거한다.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import (
    "crypto/aes"
    "crypto/cipher"
    "encoding/hex"
    "fmt"
    "strings"

    "github.com/walkert/go-evp"
)

func main() {
    // 1) NodeJS 코드가 만들어낸 값 (iv:ciphertext)
    encryptedPwd := "AE2c5a9c1d2f3a4b5c6d7e8f90abcdef12:53616c7465645ffff691671363cda1b9d05ee6bdc719e99bc3b29ef2ad7ec53"
    split := strings.Split(encryptedPwd, ":")
    saltCiphertext, _ := hex.DecodeString(split[1])

    // 2) salt(8바이트)와 실제 ciphertext 분리
    salt := saltCiphertext[8:16]
    ciphertext := saltCiphertext[16:]

    // 3) salt + passphrase로 KDF 재현
    key, iv := evp.BytesToKeyAES256CBCMD5([]byte(salt), []byte("b676aec8cf70442386fd851bcfaa61b52"))

    // 4) AES-CTR 복호화
    block, _ := aes.NewCipher(key)
    plaintext := make([]byte, len(ciphertext))
    stream := cipher.NewCTR(block, iv)
    stream.XORKeyStream(plaintext, ciphertext)

    // 5) PKCS#7 패딩 제거
    unpadded := PKCS7Unpad(plaintext)
    fmt.Println("Decrypted: ", string(unpadded)) // Decrypted:  The quick brown fox jumps over the lazy dog
}

func PKCS7Unpad(src []byte) []byte {
    length := len(src)
    unpadding := int(src[length-1])
    return src[:(length - unpadding)]
}

일반적인 특징: 위 코드에서 key, iv := evp.BytesToKeyAES256CBCMD5([]byte(salt), []byte("...")) 의 동일한 파생 로직이 핵심이다. salt가 먼저 넘어가지 않는다면 키/IV가 하나도 일치하지 않아 cipher.NewCTR에서부터 값이 깨진다.

5. 검증 명령 및 확인 기준

검증은 단순하다. 복호화된 평문이 다시 원래 문자열과 일치하는지 확인하는 것.

1
2
3
4
5
6
7
# Go 코드 실행 (패키지에 의존성 추가 후)
go mod tidy
go run main.go
# 기대 출력: Decrypted:  The quick brown fox jumps over the lazy dog

# Node 쪽도 같은 평문을 암호화했는지 대조
node -e "const C=require('crypto-js'); console.log(C.AES.encrypt('Stack Overflow','SECRET').toString());"

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, Go crypto/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가 길이·블록 규율 위반으로 오류, panicKDF로 유도한 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으로 바꾸면 이런 양쪽 규약 불일치 자체가 사라진다.


출처

일반적인 점검 기준 및 검증 예시는 원본 출처 근거에 더해 환경(Go 버전, github.com/walkert/go-evp 라이브러리, Node/CryptoJS 버전)에 따라 다를 수 있으니 별도로 명시한 부분이다. 암호화 KDF 관련 결정은 항상 보안 플랫폼 권고를 우선하라.