Published on
·
reading time
16분

왜 나는 Node.js 내장 모듈 대신 crypto-js에 SEED 암호화를 이식했나

Authors

직장에서 항공권 예약 서비스를 개발하면서 여러 항공사와 API를 연동하는 업무를 맡았다. 좌석 구매나 수하물 추가 같은 부가서비스를 구현하려면, 필연적으로 예약 번호나 실명 같은 개인정보를 주고받아야 한다. 당연하게도 이 정보들은 항공사가 요구하는 규격에 맞춰 암호화해서 전달해야 한다.

항공사 측에서 제공한 암호화 규격은 Java로 구현된 SEED 암호화 모듈을 기준으로 하고 있었다. 동일한 알고리즘과 대칭키를 사용해 암호화해야만, 항공사에서 정상적으로 복호화할 수 있는 구조였다.

한편, 우리 팀의 BFF(Backend For Frontend)는 Next.js의 Route Handlers를 중심으로 구성되어 있었다. 즉, 모든 로직은 Node.js 환경에서 실행된다. 이로써 Java 기반의 암호화 모듈을 그대로 사용할 수는 없는 상황이었고, Node.js 환경에서 동일한 결과를 만들어내는 방법을 고민해야 했다.

이 글에서는 이러한 상황에서 어떤 선택지를 검토했고, 왜 결국 직접 오픈 소스를 만들기로 결심했는지 그 과정을 회고해보고자 한다.


SEED의 JavaScript 구현체를 찾아서

가장 먼저 SEED 알고리즘의 공식 배포처인 KISA를 뒤져봤다. 하지만 Python, C/C++, Java 등 6개 언어에 대한 구현체만 있을 뿐, JavaScript 구현체는 존재하지 않았다.

그래서 다른 방법을 찾던 와중, 문득 이런 생각이 들었다. "Node.js의 crypto 모듈이 혹시 SEED 암호 알고리즘을 지원하지 않을까?"

Node.js의 crypto 모듈과 OpenSSL

Node.js의 crypto 모듈은 OpenSSL이 제공하는 해시, HMAC, 암/복호화, 서명 관련 기능을 Javascript에서 쓰기 쉽게 감싸서 제공하는 모듈이다.

즉, crypto 모듈이 지원하는 알고리즘 목록은 Node.js가 의존하는 OpenSSL 버전에 종속적이다. 그렇다면 OpenSSL이 SEED 알고리즘을 지원하는지만 확인하면 해결될 문제 아닐까?

터미널을 열어 OpenSSL이 지원하는 알고리즘 목록을 조회해 보았다.

openssl list -cipher-algorithms
↳ ...생략
  SEED-CBC
  SEED-CFB
  SEED-ECB
  SEED-OFB

확인 결과, SEED-CBC, SEED-ECB 등이 리스트에 명확히 존재했다. OpenSSL이 지원하고 있으니, 이를 감싸고 있는 Node.js crypto 모듈에서도 당연히 호출 가능할 것이라 판단했다.

곧바로 Node.js 환경에서 사용 가능한 알고리즘 목록을 확인하는 코드를 실행해 보았다.

import crypto from 'node:crypto'

const availableCiphers = crypto.getCiphers()
// 'seed'가 포함된 알고리즘 필터링
const seedCiphers = availableCiphers.filter((cipher) => cipher.includes('seed'))

console.log(seedCiphers) // []

하지만 예상과 달리 결과는 빈 배열이었다. OpenSSL 목록에는 존재하는 SEED가, Node.js crypto 모듈을 통하니 조회되지 않는 상황이었다.

원인은 OpenSSL 3.0과 Legacy Provider

이 현상의 원인은 Node.js 버전OpenSSL 3.0의 정책 변화에 있었다.

OpenSSL 3.0부터는 보안 강도가 낮거나 더 이상 권장되지 않는 오래된 알고리즘, 혹은 레거시 시스템 호환용 알고리즘들을 Legacy Provider라는 별도 모듈로 분리했다. SEED 알고리즘 역시 기본 Provider가 아닌 Legacy Provider로 분류되었다.

문제는 Node.js 17 버전부터 내부적으로 OpenSSL 3.0을 사용한다는 점이다. 그 결과, OpenSSL 3.0에서 기본 provider에 포함되지 않은 알고리즘들은 Node.js의 crypto 모듈에서도 기본적으로 로드되지 않게 되었고, SEED 역시 그 대상에 포함되었다.

즉, OpenSSL 자체는 여전히 SEED 알고리즘을 제공하고 있지만, Node.js 환경에서는 별도의 설정 없이는 일반적으로 사용할 수 없는 상태가 된 것이다.

해결 방법은 --openssl-legacy-provider 옵션 적용
Node.js 실행 시점에 Legacy Provider를 활성화해주면 된다. --openssl-legacy-provider 옵션을 추가하여 실행하면, OpenSSL의 Legacy Provider가 함께 로드되면서 SEED 알고리즘을 사용할 수 있게 된다.

# 옵션을 추가하여 실행
node --openssl-legacy-provider -e 'console.log(crypto.getCiphers())'
[
  'seed-ofb',
  'seed-ecb',
  'seed-cfb',
  'seed-cbc',
  'seed',
  ... 166 more items
]

이제 목록에서 SEED 관련 알고리즘이 확인된다. 이를 바탕으로 crypto 모듈을 사용해 SEED 암호화 클래스를 구현하면 다음과 같다.

// ❗주의: 이 코드는 반드시 `--openssl-legacy-provider` 옵션과 함께 실행해야 정상 동작한다.

import crypto from 'node:crypto'

class SeedCipher {
  private seedKey = '000102030405060708090a0b0c0d0e0f'

  private get key() {
    // 16바이트 키
    return Buffer.from(this.seedKey, 'hex')
  }

  // 암호화
  encrypt(plainText: string) {
    const cipher = crypto.createCipheriv('seed-ecb', this.key, null)

    return cipher.update(plainText, 'utf8', 'base64') + cipher.final('base64')
  }

  // 복호화
  decrypt(cipherText: string) {
    const decipher = crypto.createDecipheriv('seed-ecb', this.key, null)

    return decipher.update(cipherText, 'base64', 'utf8') + decipher.final('utf8')
  }
}

const seedCipher = new SeedCipher()

const plainText = 'HELLO, SEED'
const cipherText = seedCipher.encrypt(plainText)
const decrypted = seedCipher.decrypt(cipherText1)

console.log(cipherText) // IRpjXZ2497SANLwen8LZ6w==
console.log(decrypted) // HELLO, SEED

내장 모듈을 뒤로하고, 직접 오픈 소스를 개발하게 된 이유

앞서 살펴본 것처럼, Node.js 내장 crypto 모듈을 사용하면 SEED 암호화를 수행하는 것 자체는 가능하다. 하지만 그럼에도 불구하고, 나는 crypto-js를 포크해 SEED 알고리즘을 결합한 별도의 오픈 소스 개발을 시작하게 되었다.

NOTE

crypto-js
crypto-js는 Node.js와 브라우저 환경 어디서든 동일하게 동작하는 범용성 덕분에, 아직까지 주간 다운로드 1천만 건을 넘나들며 JavaScript 생태계의 표준처럼 사용되어 온 라이브러리다. 물론 지금은 Node.js와 모던 브라우저들이 네이티브 Crypto 모듈을 제공하게 되면서, crypto-js가 더 발전해 봤자 네이티브 기능의 단순 래퍼 역할에 지나지 않게 되었고, 자연스럽게 추가 개발과 유지보수 또한 중단된 상태다.

내가 굳이 이 길을 택한 구체적인 이유는 크게 두 가지였다.

첫째, --openssl-legacy-provider 옵션을 실제 서비스 환경에서 사용하는 데에 대한 고민이었다.
이 옵션은 OpenSSL의 legacy provider를 로드해 문제를 해결해주지만, Node.js 실행 옵션에 강하게 의존하게 되고, 배포 환경이나 런타임 구성에 따라 언제든지 예기치 않은 장애 요인이 될 수 있다. 특히 장기적으로 운영해야 하는 서비스 관점에서는, “옵션이 빠지면 동작하지 않는 암호화 로직”은 부담스러운 선택지였다.

둘째, 서로 다른 패딩(Padding) 처리 방식이다.
항공사에서 요구한 암호화 규격은 Zero Padding 방식을 따르고 있었다. 반면, Node.js 내장 crypto 모듈은 PKCS#7 패딩을 기본값으로 사용한다. 내장 모듈로 이를 해결하려면 crypto.setAutoPadding(false)로 자동 패딩을 비활성화한 뒤, 데이터 길이를 계산해 0을 채워 넣는 Zero Padding 로직과, 복호화 시 이를 제거하는 Unpadding 로직을 직접 구현해야 한다. 이는 단순히 코드가 늘어나는 건 둘째치고, 비즈니스 로직이 굳이 알 필요 없는 패딩 구현까지 일일이 신경 써야 한다는 점이 가장 큰 부담이었다.

반면 crypto-js는 AES를 비롯한 주요 대칭키 암호 알고리즘과 함께, Pkcs7, AnsiX923, ZeroPadding, NoPadding 등 다양한 패딩 방식을 모듈 형태로 분리해 제공한다. 암호화 로직과 패딩 로직이 분리되어 있어, 요구사항에 따라 패딩 방식을 손쉽게 교체해 사용할 수 있는 구조다.

예를 들어, 다음과 같이 옵션 하나만 변경해 패딩 방식을 간단히 바꿀 수 있다.

import CryptoJS from 'crypto-js'

CryptoJS.AES.encrypt('Message', 'Secret Passphrase', {
  mode: CryptoJS.mode.ECB, // CryptoJS.mode.CBC...,
  padding: CryptoJS.pad.ZeroPadding, // CryptoJS.pad.Pkcs7...
})

결국 OpenSSL 설정이나 런타임 옵션에 구애받지 않으면서, 패딩 방식까지 유연하게 제어할 수 있는 독립적인 구현체가 있으면 좋겠다는 생각이 들었다.

crypto-js 포크, 그리고 SEED 암호화 이식

결국 선택한 방법은 crypto-js를 포크해 SEED 암호화를 이식하는 것이었다.

이미 KISA에서 공식적으로 배포하는 Java 소스 코드가 존재했기에, 이를 JavaScript 문법으로 포팅한 뒤 crypto-js 구조에 맞춰 넣으면 되는 상황이었다. 마침 관련 레퍼런스를 찾던 중 누군가 포팅해 둔 소스 코드를 발견하여 이를 활용하기로 했다.

해당 코드를 그대로 사용하기보다는 OpenSSL의 SEED 암호화 결과와 일치하는지 반복 테스트를 통해 검증했고, 문제가 없음을 확인한 뒤 본격적인 이식 작업을 진행했다.

구현 방향은 기존 crypto-js의 컨벤션을 따르는 것에 맞췄다. AESDES처럼 CryptoJS.SEED.encrypt 형태로 호출할 수 있도록 인터페이스를 일치시켰고, 모듈 추가에 따른 테스트 코드도 함께 작성했다.

이와 더불어 작업 과정에서 발견한 몇 가지 문제와 개선 사항도 반영했다. 먼저 AnsiX923 패딩 모듈의 export name에 존재하던 오타로 인해 해당 기능이 정상적으로 동작하지 않는 문제를 확인했다. 이를 수정하여 내 프로젝트에 반영했고, 원본 저장소에도 해당 수정 사항이 적용될 수 있도록 PR을 보내 개선을 제안했다. (물론 원본 프로젝트는 이미 유지보수가 중단된 상태라, 아쉽게도 현재까지 반영되지는 않았다..)

마지막으로 TypeScript 지원 작업을 진행했다. crypto-js는 순수 JavaScript로 작성된 라이브러리이기 때문에, TypeScript 환경에서 사용하려면 @types/crypto-js를 별도로 설치해야 하는 번거로움이 있다. 이를 개선하기 위해 라이브러리 내부에 타입 정의를 직접 포함시켜, 추가 설치 없이 바로 사용할 수 있도록 구성했다.

완성된 오픈 소스 패키지와 사용법

이렇게 완성된 전체 소스 코드는 깃허브 저장소에서 확인할 수 있다.

이제 npm에 배포된 이 패키지 설치만으로, Node.js 환경 위에서 별도의 설정 없이 SEED 암호화를 바로 사용할 수 있다.

npm install @leo-util/crypto-js

사용법은 기존 crypto-js와 동일하며, 사실상 SEED 암호화 선택지만 추가된 형태다.

import Base64 from '@leo-util/crypto-js/enc-base64'
import Utf8 from '@leo-util/crypto-js/enc-utf8'
import ECB from '@leo-util/crypto-js/mode-ecb'
import ZeroPadding from '@leo-util/crypto-js/pad-zeropadding'
import SEED from '@leo-util/crypto-js/seed'

class SeedCipher {
  // 16바이트 키
  private seedKey: string = 'secretPassphrase'

  private get key() {
    return Utf8.parse(this.seedKey)
  }

  // 암호화
  encrypt(plainText: string) {
    const plainData = Utf8.parse(plainText)

    // 기존 crypto-js 문법 그대로, 알고리즘만 SEED로 교체
    const encResult = SEED.encrypt(plainData, this.key, {
      mode: ECB,
      padding: ZeroPadding,
    })

    return Base64.stringify(encResult.ciphertext)
  }

  // 복호화
  decrypt(cipherText: string) {
    const decResult = SEED.decrypt(cipherText, this.key, {
      mode: ECB,
      padding: ZeroPadding,
    })

    return Utf8.stringify(decResult)
  }
}

마치며

개발을 하다 보면 "세상에 없으면 내가 만들지 뭐"라는 농담을 하곤 하는데, 이번엔 그 말이 현실이 되었다.

사실 국내 환경이 아니라면 SEED 암호화를 쓸 일은 거의 없을 것이다. 최신 기술과는 거리가 있지만, 여전히 오래된 현업 시스템 곳곳에서 암호화 규격의 일부로 SEED 알고리즘이 사용되고 있다는 현실 역시 무시할 수 없을 것이다.

나처럼 항공사나 금융권의 레거시 시스템과 연동해야 하는 상황에서, Node.js 버전 호환성이나 패딩 문제로 골머리를 앓고 있는 분들에게, 이 패키지가 그 복잡함을 덜어줄 명쾌한 해답이 되었으면 하는 바람이다.