빌드노트
Node.js timingSafeEqual이 false 대신 길이 오류를 던질 때
Node.js timingSafeEqual은 같은 길이의 다른 값에는 false를 반환하지만, 바이트 길이가 다르면 예외를 던진다. LAB1 비교를 한 세션에서 이어 실행하며 UTF-8 변환 뒤 길이를 확인하고 잘못된 입력을 명시적으로 거부하는 위치를 설명한다.
Node.js에서 인증 값을 timingSafeEqual로 비교할 때, 짧거나 긴 입력은 false 대신 예외를 일으킬 수 있다. 이 함수는 바이트 길이가 같은 두 입력을 비교한다. 호출 전에 사용할 인코딩으로 변환하고, 최종 Buffer의 길이가 다르면 명시적으로 거부해야 한다. Node.js 22의 비교 함수 계약 (새 탭에서 열림)
기존 코드에서는 timingSafeEqual 호출 직전에 두 Buffer가 어떻게 만들어지는지 확인한다. if (!timingSafeEqual(...)) 안에 거부 처리를 두었더라도 길이 오류가 나면 그 분기까지 도달하지 않는다. 아래에서는 합성 기준 값 LAB1을 유지하며 어떤 입력이 예외를 만들고, 어디서 거부하면 되는지 확인한다.
같은 기준 값에 입력만 바꿔 본다
Node.js 22가 설치된 터미널에서 node -i를 실행한다. 별도 패키지는 필요 없으며, 아래 코드는 모두 같은 Node 대화형 세션에 차례로 입력한다. 먼저 동일한 값, 같은 길이의 다른 값, 짧은 값을 비교한다.
const { timingSafeEqual } = require('node:crypto');
const { Buffer } = require('node:buffer');
const expected = Buffer.from('LAB1', 'utf8');
timingSafeEqual(Buffer.from('LAB1', 'utf8'), expected);
timingSafeEqual(Buffer.from('LAB2', 'utf8'), expected);
timingSafeEqual(Buffer.from('LAB', 'utf8'), expected);
Windows의 Node.js v22.23.2에서 확인한 세 호출의 결과다. 선언문의 undefined와 대화형 프롬프트는 생략했다.
true
false
Uncaught [RangeError: Input buffers must have the same byte length] {
code: 'ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH'
}
LAB2는 기준 값과 같은 4바이트여서 비교 결과가 false다. LAB은 3바이트이므로 예외가 발생했고, 오류의 code는 ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH였다. 대화형 세션은 이 오류 뒤에도 계속 사용할 수 있다. 자기 코드에서 같은 오류가 나면 실제 값 자체를 로그에 남기기보다 호출 직전 두 Buffer의 byteLength와 사용한 인코딩을 확인한다. 같은 입력으로도 결과가 다르면 node --version과 앞선 파싱·디코딩 단계도 확인한다.
비교 가능한 길이인지를 문자열 length만으로 판단해서는 안 된다. LAB1의 A를 가로 바꾼 입력으로 문자열 길이와 변환 뒤 길이를 나란히 확인한다.
['L가B1'.length, Buffer.from('L가B1', 'utf8').byteLength]
[ 4, 6 ]
문자열 length는 여전히 4지만 UTF-8 Buffer는 6바이트다. 이 Buffer를 expected와 직접 비교한 실행에서도 같은 길이 오류가 났다. 따라서 text.length === 4만 검사하면 이 입력을 걸러내지 못한다. 비교에 넘길 Buffer를 만든 뒤 그 바이트 길이를 검사한다. Buffer 변환과 인코딩 (새 탭에서 열림), 문자열 길이와 바이트 길이의 차이 (새 탭에서 열림)
변환을 끝낸 Buffer에서 거부한다
이 수정은 정해진 바이트 길이가 공개되어도 되는 UTF-8 문자열 규약을 전제로 한다. 서버에서 기준 값을 비어 있지 않은 Buffer로 준비해 두어야 한다. 여기서는 앞서 만든 expected를 그대로 쓰며, 합성 값의 4바이트를 실제 인증 토큰의 권장 길이로 삼지는 않는다.
같은 세션에 다음 함수를 추가하면 문자열이 아닌 입력과 바이트 길이가 다른 입력을 비교 전에 거부할 수 있다. 마지막 줄은 앞의 세 입력에 Unicode 입력과 null을 더해 수정 결과를 확인한다.
function matches(text) {
if (typeof text !== 'string') return false;
const actual = Buffer.from(text, 'utf8');
if (actual.byteLength !== expected.byteLength) return false;
return timingSafeEqual(actual, expected);
}
[matches('LAB1'), matches('LAB2'), matches('LAB'), matches('L가B1'), matches(null)]
[ true, false, false, false, false ]
짧은 LAB과 6바이트인 L가B1은 예외 대신 false로 끝났다. 같은 길이의 LAB2도 내용 비교에서 거부됐다. 별도로 빈 문자열과 긴 LAB12를 넣었을 때도 false였으며, 길이 불일치와 타입 불일치에서는 비교 함수가 호출되지 않았다. 호출자는 이 함수의 false를 기존 인증 실패 경로에 연결한다. 길이를 맞추려고 입력을 자르거나 채우면 원래 받은 값과 다른 값을 비교하므로 그렇게 보정하지 않는다. 확인을 마치면 .exit로 대화형 세션을 끝낸다.
실제 규약이 hex나 Base64로 표현한 바이트를 비교한다면 해당 규약의 입력 검증과 디코딩을 먼저 끝내고, 최종 Buffer에 길이 검사를 적용한다. 위 함수는 UTF-8 문자열 비교만 다루며 다른 인코딩의 유효성 검사나 요청 크기 제한을 대신하지 않는다.
길이가 다를 때 먼저 반환하므로 같은 길이일 때와 실행 경로가 달라진다. 길이 노출을 허용하는 규약에서만 이 분기를 사용해야 한다. 공식 문서도 timingSafeEqual을 쓴다는 사실만으로 주변 코드 전체의 시간 안전성을 보장하지 않는다고 설명한다. 시간 안전성의 적용 범위 (새 탭에서 열림)
이번 실행은 소유한 합성 Buffer의 반환값·예외와 수정 함수의 거부 동작을 확인했다. 실제 인증 토큰이나 서버 요청은 사용하지 않았고, 마이크로벤치마크로 전체 인증 경로가 constant-time인지 검증하지도 않았다.