CryptoJS 와 OpenSSL 인코딩
Node.js 암호화 라이브러리 crypto-js와 node-forge를 비교 분석하며, crypto-js의 OpenSSL 호환 암호화 방식에 대해 자세히 알아봅니다.
Node.js 에서 암호화 관련 기능을 개발하다, 두 가지 라이브러리를 살펴보게 되었다. crypto-js 와 node-forge 이다. crypto-js 는 node-forge 에 비해 사용법이 간단했다. 아무래도 이러한 crypto-js 의 특성으로 인해 더 많은 star 를 받은 것 같다. crypto-js 는 암호학에 대한 많은 지식이 없어도 암호화 관련 기능을 쉽게 개발할 수 있게 해준다. 하지만 두 라이브러리를 살펴보던 중, 예상치 못한 문제를 발견했다. 아래의 두 코드는 AES-CBC(PKCS7 Padding) 암호화를 crypto-js 와 node-forge 를 사용해서 구현한 것이다.
먼저 crypto-js 로 구현한 예시이다.
/** 암호화 */
function encryptAes(data: string, key: string): string {
const cipher = CryptoJS.AES.encrypt(data, key, {
padding: CryptoJS.pad.Pkcs7,
mode: CryptoJS.mode.CBC,
});
return cipher.toString();
}
/** 복호화 */
function decryptAes(encrypted: string, key: string): string {
const cipherParams = CryptoJS.lib.CipherParams.create({
ciphertext: CryptoJS.enc.Base64.parse(encrypted),
});
const cipher = CryptoJS.AES.decrypt(cipherParams, key, {
padding: CryptoJS.pad.Pkcs7,
mode: CryptoJS.mode.CBC,
});
return cipher.toString(CryptoJS.enc.Utf8);
}
다음은 node-forge 로 구현한 예시이다.
/** 암호화 */
function encryptAes(data: string, key: string, iv: string): string {
const buffer = Buffer.from(data);
const cipher = forge.cipher.createCipher(
"AES-CBC",
forge.util.createBuffer(buffer)
);
cipher.start({
iv: forge.util.createBuffer(
Buffer.from(iv, "base64")
),
});
cipher.update(forge.util.createBuffer(Buffer.from(data)));
cipher.finish();
return cipher.output;
}
/** 복호화 */
function decryptAes(data: string, key: string, iv: string) {
const buffer = Buffer.from(data, "base64");
const decipher = forge.cipher.createDecipher("AES-CBC", key);
decipher.start({
iv: forge.util.createBuffer(
Buffer.from(iv, "base64")
),
});
decipher.update(forge.util.createBuffer(buffer));
decipher.finish();
return decipher.output;
}
그런데 여기에서 가장 먼저 이상한 점 한 가지를 발견했다.
- 동일한
AES-CBC(PKCS7 Padding)암/복호화 함수인데, 두 라이브러리의 함수 시그니처는 다른 것일까 ?
라이브러리를 더 살펴보니, crypto-js 에서도 암/복호화할 때 iv 옵션을 줄 수 있었다. 그런데, 여기서 더 이상한 사실을 발견했다.
crypto-js 로 암/복호화할 때에, iv 값을 아무렇게나 넣어도 암/복호화에 전혀 영향을 미치지 않는 것이다. 예를 들어, 암호화할 때 iv 값을 12345678 로 설정하고, 복호화할 때에 iv 값을 00000000 로 설정해도, 암, 복호화가 모두 정상적으로 수행된다는 것이다. crypto-js 에서 무언가 매직박스와 같은 기능이 수행되고 있다는 생각이 들어서, 좀 더 파고들어보기로 하였다.
crypto-js 코드를 살펴보다가, 다음과 같은 주석을 발견하게 되었다.
interface Format {
/**
* Converts a cipher params object to an OpenSSL-compatible string.
*
* @param cipherParams The cipher params object.
* @return The OpenSSL-compatible string.
* @example
* var openSSLString = CryptoJS.format.OpenSSL.stringify(cipherParams);
*/
stringify(cipherParams: CipherParams): string;
}
crypto-js 의 암호화 형식은 디폴트로 OpenSSL 형식(Base64)으로 인코딩함을 알 수 있다. 이러한 내용은 공식문서 에도 표기되어 있다. 실제로, openssl cli 로 aes256 암호화를 한 후, cryptojs 로 복호화하는 것이 가능하다.
-- 암호화 (bash)
openssl enc -aes-256-cbc -pass pass:mypassword -p -in ./decrypted.txt -out encrypted.enc
// 복호화 (js)
const cipher = CryptoJS.AES.decrypt(plain, "mypassword", {
padding: CryptoJS.pad.Pkcs7,
mode: CryptoJS.mode.CBC,
});
const decrypted = cipher.toString(CryptoJS.enc.Utf8)
물론, CryptoJS 에서 OpenSSL 인코딩만 사용해야 하는 것은 아니다. Hex 인코딩도 지원한다.
그렇다면, OpenSSL 스펙은 어떤 것일까 ? 구글링을 통해, 다음과 같은 형태로 구성됨을 알 수 있었다.
| item | description | length |
| --- | --- | --- |
| "Salted__" | 이 문자열을 통해, 해당 데이터가 OpenSSL 로 암호화된 것임을 알 수 있다 | 8 byte |
| salt | 암호화를 할 때 사용되는 임의의 값. 빈번히 변경됨으로써 보안을 높일 수 있다 | 8 byte |
| encrypted_data | salt 와 암호화 key 를 사용해 암호화된 데이터를 저장하는 부분이다 | n byte |
위의 OpenSSL 스펙대로 AES256 암호화를 다시 작성해 보았다.
import * as forge from '@tossteam/forge';
import { OpensslAesEncryptionError } from './error';
/**
*
* @publicApi
* @see https://github.com/digitalbazaar/forge#ciphers-1
*/
export class OpensslAesEncryptor {
private readonly keySize: number;
// AES-CBC (AES256) 암호화
private readonly algorithm = 'AES-CBC';
constructor(private readonly key: string, keySizeBit?: 128 | 192 | 256) {
const keySize = keySizeBit ? this.bitToByte(keySizeBit) : Buffer.from(key).byteLength;
this.keySize = keySize;
if (![16, 24, 32].includes(this.keySize)) {
throw new OpensslAesEncryptionError(
'key 길이는 16(AES-128), 24(AES-192), 32(AES-256) 중 하나여야 합니다',
);
}
}
encrypt(raw: string): string {
// 8바이트 길의의 salt 를 랜덤으로 생성해낸다
const salt = forge.random.getBytesSync(this.saltSize);
// key 를 암호화에 사용할 수 있는 형태로 key, iv 를 유도해낸다.
const derivedBytes = (forge as any).pbe.opensslDeriveBytes(
this.key,
salt,
this.keySize + this.ivSize,
);
// buffer 타입으로 변경
const buffer = forge.util.createBuffer(derivedBytes);
// 첫 32 바이트는 key 가 된다
const key = buffer.getBytes(this.keySize);
// 이후의 16 바이트는 iv 가 된다
const iv = buffer.getBytes(this.ivSize);
// 암호화
const cipher = forge.cipher.createCipher(this.algorithm, key);
cipher.start({ iv: iv });
cipher.update(forge.util.createBuffer(raw));
if (!cipher.finish()) {
throw new OpensslAesEncryptionError('AES256 암호화에 실패하였습니다');
}
const output = forge.util.createBuffer(`${this.saltPrefix}${salt}${cipher.output.data}`);
return forge.util.encode64(output.data);
}
decrypt(base64: string): string {
// "SALT_PREFIX" 의 base64 인코딩 값
const saltPrefixSignature = 'U2FsdGVkX1';
if (!base64.startsWith(saltPrefixSignature)) {
throw new OpensslAesEncryptionError(
`암호화된 값이 Salt 형식이 아닙니다. (Salt 암호화된 값은 "${this.saltPrefix}" 로 시작해야 합니다)`,
);
}
const inputBuffer = forge.util.createBuffer(forge.util.decode64(base64));
// 앞의 prefix ("Salted__") 부분은 제외한다
inputBuffer.getBytes(this.saltPrefix.length);
// "Salted__" 뒤의 8바이트가 실질적인 Salt 값이다
const salt = inputBuffer.getBytes(this.saltSize);
// salt 부분을 제외한, 암호화된 부분
const input = forge.util.decode64(base64).slice(this.saltPrefix.length + this.saltSize);
// key 를 암호화에 사용할 수 있는 형태로 key, iv 를 유도해낸다.
const derivedBytes = (forge.pki as any).pbe.opensslDeriveBytes(
this.key,
salt,
this.keySize + this.ivSize,
);
// buffer 타입으로 변경
const buffer = forge.util.createBuffer(derivedBytes);
// 첫 32 바이트는 key 가 된다
const key = buffer.getBytes(this.keySize);
// 뒤의 16바이트는 iv가 된다
const iv = buffer.getBytes(this.ivSize);
const decipher = forge.cipher.createDecipher(this.algorithm, key);
decipher.start({ iv: iv });
decipher.update(forge.util.createBuffer(input));
if (!decipher.finish()) {
throw new OpensslAesEncryptionError('AES256 복호화에 실패하였습니다');
}
return decipher.output.data;
}
private bitToByte(bit: number): number {
if (bit % 8 !== 0) {
throw new OpensslAesEncryptionError('비트가 8의 배수가 아닙니다');
}
return bit / 8;
}
get saltPrefix(): string {
return 'Salted__';
}
/** 8 바이트 고정값 */
get saltSize(): number {
return 8;
}
/** 16 바이트 고정값 */
get ivSize(): number {
return 16;
}
}
이것도 읽어보세요