CryptoJS 와 OpenSSL 인코딩

Node.js 암호화 라이브러리 crypto-js와 node-forge를 비교 분석하며, crypto-js의 OpenSSL 호환 암호화 방식에 대해 자세히 알아봅니다.

Node.js 에서 암호화 관련 기능을 개발하다, 두 가지 라이브러리를 살펴보게 되었다. crypto-jsnode-forge 이다. crypto-jsnode-forge 에 비해 사용법이 간단했다. 아무래도 이러한 crypto-js 의 특성으로 인해 더 많은 star 를 받은 것 같다. crypto-js 는 암호학에 대한 많은 지식이 없어도 암호화 관련 기능을 쉽게 개발할 수 있게 해준다. 하지만 두 라이브러리를 살펴보던 중, 예상치 못한 문제를 발견했다. 아래의 두 코드는 AES-CBC(PKCS7 Padding) 암호화를 crypto-jsnode-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;
}

그런데 여기에서 가장 먼저 이상한 점 한 가지를 발견했다.

라이브러리를 더 살펴보니, 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;
  }
}

이것도 읽어보세요