본문 바로가기
빌드노트 검색

빌드노트

Node PBKDF2는 32바이트인데 WebCrypto는 왜 4바이트일까

Node PBKDF2의 keylen은 바이트, WebCrypto deriveBits의 length는 비트다. 같은 UTF-8 입력에서 32바이트와 32·256비트를 비교해 단위 변환을 확인하고, 길이를 맞춘 뒤에는 비밀번호·salt·반복 수·해시까지 대조해야 하는 이유를 설명한다.

Node.js의 pbkdf2Sync에서 쓰던 길이 32를 WebCrypto의 deriveBits에 그대로 넣으면 4바이트만 나온다. 두 API가 길이를 받는 단위가 다르기 때문이다. Node의 keylen은 바이트, WebCrypto의 length는 비트다. 32바이트가 필요하다면 deriveBits에는 32 * 8, 즉 256을 전달한다. Node PBKDF2 문서 (새 탭에서 열림)와 WebCrypto deriveBits 문서 (새 탭에서 열림)에서 각각의 단위를 확인할 수 있다.

같은 입력에서 길이 인자만 바꾸면

아래 비교는 Windows의 Node.js v22.23.2에서 실행했다. WebCrypto도 같은 Node 프로세스의 webcrypto.subtle을 쓴다. 비밀번호와 salt는 공개 합성 문자열이며, 반복 수 1,000은 작은 비교를 위한 값이다. 이 반복 수와 고정 salt를 운영 비밀번호 정책으로 제안하는 예제가 아니다.

다음 코드는 같은 비밀번호·salt·반복 수·SHA-256을 사용하고, WebCrypto가 요청받는 길이만 32비트와 256비트로 바꿔 실제 바이트 수와 일치 여부를 확인한다. UTF-8로 저장한 pbkdf2-units.mjs 파일에 넣고 node pbkdf2-units.mjs로 실행한다. 별도 패키지는 필요 없다.

import { pbkdf2Sync, webcrypto } from 'node:crypto';

const { subtle } = webcrypto;
const passwordText = '공개-pass-가';
const saltText = '공개-salt-20261023';
const encoder = new TextEncoder();
const password = Buffer.from(passwordText, 'utf8');
const salt = Buffer.from(saltText, 'utf8');
const iterations = 1000; // 단위 비교용이며 운영 권장값이 아니다.
const keyBytes = 32;

const nodeKey = pbkdf2Sync(password, salt, iterations, keyBytes, 'sha256');
const baseKey = await subtle.importKey(
  'raw', encoder.encode(passwordText), 'PBKDF2', false, ['deriveBits']
);
const params = {
  name: 'PBKDF2', salt: encoder.encode(saltText),
  iterations, hash: 'SHA-256'
};
const short = await subtle.deriveBits(params, baseKey, keyBytes);
const correct = await subtle.deriveBits(params, baseKey, keyBytes * 8);

console.log('Node bytes:', nodeKey.byteLength);
console.log('WebCrypto 32 bits bytes:', short.byteLength);
console.log('WebCrypto 256 bits bytes:', correct.byteLength);
console.log('32 bits equals Node:', nodeKey.equals(Buffer.from(short)));
console.log('256 bits equals Node:', nodeKey.equals(Buffer.from(correct)));

실제로 나온 출력은 다음과 같다. byteLength는 결과를 hex나 Base64 문자열로 바꾸기 전의 바이트 수다.

Node bytes: 32
WebCrypto 32 bits bytes: 4
WebCrypto 256 bits bytes: 32
32 bits equals Node: false
256 bits equals Node: true

32를 그대로 옮긴 호출도 정상 반환했다. 32비트는 4바이트로 표현할 수 있는 길이이기 때문이다. W3C의 PBKDF2 절차 (새 탭에서 열림)는 길이가 8의 배수인지 확인하고, 실제 PBKDF2 계산에는 그 값을 8로 나눈 바이트 길이를 사용한다. 따라서 이 실수는 예외 발생 여부만으로 찾을 수 없다.

자신의 코드에서도 우선 변환 전 결과의 byteLength를 확인한다. 목표가 32바이트인데 결과가 4바이트라면 deriveBits의 세 번째 인자를 바이트 단위로 넘겼는지 본다. 위 비교에서는 그 인자를 keyBytes * 8로 고쳤을 때 길이뿐 아니라 바이트 전체도 Node 결과와 일치했다.

둘 다 32바이트인데 값이 다르다면

길이를 맞춘 뒤에는 입력 바이트를 확인해야 한다. 위 코드는 Node 입력을 Buffer.from(문자열, 'utf8')로 만들고, WebCrypto 입력을 TextEncoder로 만들었다. TextEncoder 문서 (새 탭에서 열림)에 따르면 encode는 UTF-8 바이트 배열을 반환한다. 별도 비교에서도 이 예제의 비밀번호와 salt는 두 방식으로 만든 바이트가 각각 같았다.

같은 파일 아래에 다음 코드를 덧붙여 다시 실행하면, 비밀번호만 UTF-16LE로 인코딩했을 때 출력 길이는 같아도 값은 달라지는지 확인할 수 있다. salt·반복 수·해시·출력 길이는 앞의 값 그대로다.

const otherEncoding = pbkdf2Sync(
  Buffer.from(passwordText, 'utf16le'), salt, iterations, keyBytes, 'sha256'
);
console.log('UTF-16LE password output bytes:', otherEncoding.byteLength);
console.log('UTF-16LE password equals WebCrypto:',
  otherEncoding.equals(Buffer.from(correct)));

추가된 출력은 다음과 같다.

UTF-16LE password output bytes: 32
UTF-16LE password equals WebCrypto: false

출력 길이는 여전히 32바이트다. 하지만 같은 문자열을 다른 바이트로 인코딩했으므로 값은 일치하지 않았다. 길이가 같다는 것만으로 비밀번호와 salt까지 같다고 판단할 수는 없다.

두 API 사이에서 맞출 값은 비밀번호 바이트, salt 바이트, 반복 수, 해시, 출력 길이다. 위 코드에서는 sha256과 SHA-256이 같은 해시를 가리키며, 출력 길이만 각 API의 단위로 변환한다. 설정과 변수 이름에도 keyBytes처럼 단위를 남기면 호출 경계에서 8을 곱할 위치를 알 수 있다. 이미 비트로 저장한 값에 다시 8을 곱하지 않도록 한다.

이 비교는 Node의 PBKDF2와 Node에 포함된 WebCrypto의 원시 출력에 관한 것이다. 실제 브라우저 실행, deriveKey로 다른 알고리즘의 키를 만드는 경로, 운영 환경에 적절한 반복 수는 검증하지 않았다.

← 전체 글 보기