빌드노트
Node.js HMAC 값이 다르다면 hex 키를 문자열로 넘겼는지 확인한다
Node.js createHmac에 hex 키 문자열을 그대로 넘기면 글자들의 UTF-8 바이트가 키가 된다. 같은 메시지와 직접 지정한 바이트 배열로 HMAC을 대조하고, 잘못된 hex가 잘리는 동작을 확인해 디코딩 전 검증을 적용한다. digest의 출력 인코딩과 키의 표현도 구분한다.
Node.js에서 같은 키와 메시지를 썼다고 생각했는데 상대가 계산한 HMAC과 값이 다르다면, createHmac()에 넘기는 키부터 확인한다. 프로토콜이 키를 16진수(hex)로 표현한 문자열로 전달하는데 그 문자열을 그대로 넘기면, 원래 키 바이트 대신 글자들의 UTF-8 바이트가 키로 쓰인다. 이 경우에는 hex 입력을 검증한 뒤 Buffer.from(keyHex, 'hex')로 디코딩해 전달해야 한다.
먼저 연동 문서에서 키 필드의 의미를 확인한다. 0011이라는 값이 두 바이트를 표현한 hex인지, 문자 네 개 자체를 키로 쓰는 텍스트인지가 기준이다. 값이 hex처럼 생겼다는 이유만으로 디코딩하면 안 된다. 아래는 키 필드가 hex라고 정해진 경우를 공개 합성 데이터로 비교한 실험이다. 실제 서명 서비스나 비밀 설정은 사용하지 않았다.
같은 문자열에서 서로 다른 키가 만들어진다
다음 코드는 메시지와 HMAC 알고리즘을 고정하고, 키를 문자열로 넘긴 결과와 hex로 디코딩한 결과를 비교한다. 디코딩 결과가 맞는지는 별도로 숫자를 지정한 바이트 배열과 대조한다. Node.js 22가 설치된 환경이면 별도 패키지가 필요 없다. 빈 작업 폴더에 아래 내용을 hmac-demo.cjs로 저장하고 node hmac-demo.cjs를 실행한다. 예제의 키와 메시지는 공개 실습용 고정값이며, 실제 키의 생성 방법이나 권장 길이를 뜻하지 않는다.
const { createHmac } = require('node:crypto');
const { Buffer } = require('node:buffer');
const keyHex = '00112233445566778899aabbccddeeff';
const message = Buffer.from('order=demo&amount=100', 'utf8');
const keyBytes = Buffer.from(keyHex, 'hex');
const referenceKey = Buffer.from([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
]);
const mac = (key) => createHmac('sha256', key).update(message).digest('hex');
console.log('text key:', mac(keyHex));
console.log('hex key:', mac(keyBytes));
console.log('matches explicit bytes:',
keyBytes.equals(referenceKey) && mac(keyBytes) === mac(referenceKey));
console.log('text equals UTF-8:',
mac(keyHex) === mac(Buffer.from(keyHex, 'utf8')));
Windows의 Node.js v22.23.2, OpenSSL 3.5.7에서 얻은 출력이다.
text key: ef0ceba98e54f384c897cd8aacc3aef1949721b0874835e1ad6de8375c07b869
hex key: 7b7080bae597fd107d09c57a616d69c72384e9ad379be8d2315f446b46dd9c54
matches explicit bytes: true
text equals UTF-8: true
첫 두 값은 다르다. 이 예제의 keyHex는 32글자이고, UTF-8로 바꾸면 32바이트다. hex로 읽으면 두 글자가 한 바이트가 되어 16바이트다. 시작 부분만 봐도 문자열의 '00'은 글자 '0' 두 개지만, hex의 00은 값이 0인 한 바이트다. HMAC에 들어간 키가 처음부터 달랐다.
matches explicit bytes: true는 hex로 복원한 바이트와 직접 지정한 배열이 같고, 두 키의 HMAC도 같다는 뜻이다. 마지막 줄은 인코딩 옵션 없이 넘긴 문자열 키가 명시적으로 UTF-8로 만든 Buffer와 같은 결과를 냈음을 보여 준다. Python에서도 같은 숫자 배열과 메시지로 따로 계산했을 때 두 HMAC 값이 각각 일치했다. 이 대조가 확인한 것은 이 합성 입력의 키 해석 차이다.
공식 문서에서 createHmac의 키와 encoding 옵션 (새 탭에서 열림)은 문자열 키의 인코딩을 지정하는 자리다. Buffer.from의 encoding (새 탭에서 열림)은 문자열을 어떤 바이트로 바꿀지 정한다. 기존 코드에서 키 필드가 hex임을 확인했다면, 그 필드를 검증하고 디코딩하는 지점을 createHmac() 호출 앞에 둔다.
반대로 이 예제를 그대로 실행했는데 마지막 두 줄이 true가 아니거나 위 값과 다르다면 node --version과 복사한 입력을 먼저 확인한다. 특히 메시지 끝에 줄바꿈을 추가하지 않았는지 확인한다. 예제는 맞는데 실제 연동만 다르면 양쪽의 알고리즘, 키 필드의 표현, 서명 대상 메시지 바이트를 대조한다. 실제 비밀 키를 출력해서 맞추는 방식으로 확인하지 않는다.
digest의 hex는 키를 디코딩하지 않는다
위 코드에는 이미 .digest('hex')가 있지만 문자열 키의 HMAC은 여전히 달랐다. 이 인자는 계산 결과를 hex 문자열로 반환하라는 뜻이다. 앞에서 받은 키의 해석을 바꾸지 않는다. hmac.digest 문서 (새 탭에서 열림)도 이 인코딩을 반환값에 적용한다고 설명한다.
키의 표현을 읽는 것은 Buffer.from(keyHex, 'hex'), 메시지를 넣는 것은 .update(message), 결과를 표현하는 것은 .digest('hex')다. .update(text, 'hex')처럼 메시지 쪽 인자를 바꿔서 키 문제를 고치려 하면 서명 대상이 바뀐다. 위 실험은 메시지를 UTF-8 Buffer 하나로 고정해 이 차이를 섞지 않았다. hmac.update의 inputEncoding (새 탭에서 열림)은 문자열 메시지에 적용된다.
Node.js 22에서는 createHmac('sha256', keyHex, { encoding: 'hex' })로 문자열 키의 인코딩을 지정해도 이 예제의 keyBytes와 같은 결과가 나왔다. 다만 그 옵션도 다음의 입력 검증을 대신하지 않는다. 검증을 마친 Buffer를 넘기면 키를 바이트로 복원한 위치를 코드에서 분명하게 볼 수 있다.
Buffer가 만들어졌다고 올바른 hex였던 것은 아니다
Buffer.from(text, 'hex')가 잘못된 입력을 모두 예외로 거부한다고 생각하면 또 다른 오류가 남는다. 같은 환경에서 Buffer.from('0011zz', 'hex').toString('hex')는 '0011'을 반환했고, 홀수 자리인 '001'은 '00'이 됐다. 둘 다 예외는 없었다. 공식 Buffer의 hex 설명 (새 탭에서 열림)도 잘못된 문자나 짝이 없는 마지막 자리에서 데이터가 잘릴 수 있다고 명시한다.
따라서 여기서는 디코딩 전에 비어 있지 않은 문자열, 짝수 자리, ASCII hex 문자만 허용한다. 접두사나 구분자 없이 hex 문자만 전달하는 규약을 전제로 한 검사다. 앞의 파일 끝에 다음 코드를 덧붙여 같은 명령으로 실행하면 정상 키의 결과를 유지하면서 잘못된 문자를 거부하는지 확인할 수 있다.
function decodeHexKey(text) {
if (typeof text !== 'string' || text.length === 0 ||
text.length % 2 !== 0 || /[^0-9a-fA-F]/.test(text)) {
throw new TypeError('Invalid hex key');
}
return Buffer.from(text, 'hex');
}
console.log('validated:', mac(decodeHexKey(keyHex)));
try {
decodeHexKey('0011zz');
} catch (error) {
console.log(error.message);
}
앞의 출력에 다음 두 줄이 붙었다.
validated: 7b7080bae597fd107d09c57a616d69c72384e9ad379be8d2315f446b46dd9c54
Invalid hex key
'0011zz'를 '001'로 바꿔 실행해도 같은 오류 메시지가 나온다. 별도 검사에서는 빈 문자열, 문자열이 아닌 값, 0x 접두사, 공백과 끝의 줄바꿈도 디코딩 전에 거부했다. 대문자 hex는 소문자와 같은 바이트로 복원됐다. 이 함수는 입력을 잘라 내거나 공백을 없애서 통과시키지 않는다. 실제 형식에 접두사나 구분자가 있다면 그 처리도 해당 프로토콜에 맞춰 별도로 정해야 한다.
마지막 try/catch는 예제가 오류를 냈는지 보여 주는 코드다. 실제 호출에서는 mac(decodeHexKey(keyHex))처럼 검증을 통과한 키만 전달하고, 실패하면 그 입력으로 HMAC을 계산하지 않는다. 이 함수는 hex 문법만 검사한다. 실제 프로토콜이 요구하는 키의 바이트 길이와 사용 가능한 키인지는 별도로 확인해야 한다.
직접 실행한 범위는 Node.js v22.23.2의 HMAC-SHA-256, 공개 합성 키 한 개와 고정 메시지다. 다른 Node.js 버전이나 실제 상대 서비스와의 연동은 검증하지 않았다. 실제 값이 계속 다를 때는 키를 다시 인코딩하는 시도를 반복하기보다, 키를 바이트로 복원하는 경계와 메시지를 만드는 경계를 각각 확인해야 한다.