빌드노트
원본 키와 salt가 같은데 HKDF 결과가 다르다면 info를 본다
HKDF는 원본 키와 salt가 같아도 info가 다르면 다른 키를 만들 수 있다. 합성 입력으로 용도 문자열·빈 info·줄바꿈·인코딩 차이를 비교하고, Node hkdfSync와 WebCrypto deriveBits에 같은 바이트와 출력 길이를 적용해 대조한다.
HKDF로 만든 키가 양쪽에서 달라 인증 결과가 맞지 않는다면, 원본 키(IKM)와 salt만 비교해서는 부족하다. 해시와 출력 길이가 같아도 info 바이트가 다르면 파생 계산의 입력이 달라진다. 한쪽은 인증용 문자열을 넣고 다른 쪽은 빈 값을 넣는 경우가 여기에 해당한다.
기존 코드에서는 HKDF를 호출하는 두 지점의 info 생성부터 확인한다. 설정에 적힌 이름이 같다는 것보다, 호출 직전에 어떤 바이트가 들어가는지가 중요하다. 용도·버전 문자열, 대소문자, 끝의 줄바꿈과 문자 인코딩까지 맞춰야 한다. 아래에서는 새 공개 합성 키로 이 차이만 재현한다. 실제 서비스의 키나 토큰은 사용하지 않았다.
info는 파생 키에 용도를 붙이는 입력이다
RFC 5869의 2.2·2.3절 (새 탭에서 열림)은 IKM과 salt로 중간 키를 얻는 Extract 단계, 중간 키와 info로 원하는 길이의 키를 만드는 Expand 단계를 정의한다. 따라서 같은 IKM과 salt를 썼다는 확인은 info까지 같다는 확인을 대신하지 않는다.
같은 RFC의 3.2절 (새 탭에서 열림)은 info로 파생 키를 애플리케이션의 용도와 문맥에 연결한다고 설명한다. 예를 들어 note:v1:auth와 note:v1:export는 여기서 정한 서로 다른 용도다. 다른 용도의 키를 얻으려고 다르게 넣은 값이라면, 결과가 다른 것은 의도에 맞는다. 서로 통신하는 두 구현이 같은 용도의 키를 만들어야 할 때 이 값이 어긋나는 것이 문제다.
다음 코드는 IKM·salt·SHA-256·출력 길이를 고정하고 info만 바꾼다. Node.js가 설치된 빈 폴더에 UTF-8 파일 hkdf-demo.mjs로 저장하고 node hkdf-demo.mjs를 실행한다. 별도 패키지는 필요 없다. 직접 실행한 환경은 Windows 11, Node.js v22.23.2, OpenSSL 3.5.7이다. 키와 salt의 고정값은 비교용 입력이며 운영 키 생성 정책을 제안하는 값이 아니다.
import { Buffer } from 'node:buffer';
import { hkdfSync, webcrypto } from 'node:crypto';
const ikm = Buffer.from(
'6f4e2d0c8ba98765432110ffeeddccbbaa99887766554433221100a1b2c3d4e5', 'hex');
const salt = Buffer.from('8d73e9016ac245bf907e12d8463ca05b', 'hex');
const keyBytes = 32;
const utf8 = text => Buffer.from(text, 'utf8');
const derive = info => Buffer.from(
hkdfSync('sha256', ikm, salt, info, keyBytes));
const authInfo = utf8('note:v1:auth');
const auth = derive(authInfo);
const exported = derive(utf8('note:v1:export'));
const empty = derive(Buffer.alloc(0));
console.log('output bytes:', auth.length, exported.length, empty.length);
console.log('same info repeats:', auth.equals(derive(authInfo)));
console.log('auth/export equal:', auth.equals(exported));
console.log('auth/empty equal:', auth.equals(empty));
console.log('empty forms equal:', empty.equals(derive(utf8(''))));
console.log('auth/newline equal:', auth.equals(derive(utf8('note:v1:auth\n'))));
실제 출력은 다음과 같다. 세 결과의 길이는 모두 32바이트지만 값은 같지 않았다.
output bytes: 32 32 32
same info repeats: true
auth/export equal: false
auth/empty equal: false
empty forms equal: true
auth/newline equal: false
same info repeats: true는 같은 계약으로 다시 계산했을 때 결과가 재현됐다는 뜻이다. 반면 용도를 바꾸거나, 빈 info를 쓰거나, 줄바꿈 하나를 붙인 결과는 auth와 달랐다. 예제가 맞는데 실제 연동만 실패한다면, 양쪽이 같은 용도 문자열을 선택하는지와 설정을 읽는 과정에서 줄바꿈이 붙는지부터 좁힐 수 있다. 이 예제 자체의 출력이 다르면 node --version과 복사한 IKM·salt·문자열을 먼저 확인한다.
별도 비교에서는 auth와 exported를 각각 HMAC-SHA-256 키로 삼아 같은 합성 메시지 synthetic-message:42를 계산했다. 태그가 달랐고, 같은 auth 입력으로 재계산한 태그는 일치했다. 파생 키 단계의 불일치가 뒤의 인증값 비교까지 이어질 수 있음을 보여 주는 작은 예다. 실제 인증 프로토콜을 검증한 결과는 아니다.
빈 info를 쓰는 것과 인자를 빠뜨리는 것은 다르다
위 코드의 Buffer.alloc(0)과 utf8('')는 둘 다 길이가 0인 입력이라 같은 키를 만들었다. 그러나 빈 값은 note:v1:auth의 대체 표기가 아니다. 양쪽을 일단 맞추려고 모든 용도의 info를 비우면, 원래 구분하려던 용도까지 없앤다. 기존 프로토콜이 정한 값에 맞춰야 한다.
Node.js v22.23.2의 hkdfSync 문서 (새 탭에서 열림)는 info 인자를 요구하면서 길이 0을 허용한다. 같은 버전의 WebCrypto HkdfParams 문서 (새 탭에서 열림)도 같다. 실제로 Node 호출에 undefined를 넘긴 경우와 WebCrypto 객체에서 info 필드를 뺀 경우는 모두 TypeError였다. RFC에서 선택 입력이라고 한 것을 API 인자를 생략해도 된다는 뜻으로 옮기면 안 된다.
Node와 WebCrypto에도 같은 바이트를 넣는다
API를 바꿀 때는 파생 계약을 그대로 옮기고 각 API의 길이 단위만 맞춘다. hkdfSync의 keylen은 바이트이고, subtle.deriveBits의 length (새 탭에서 열림)는 비트다. 여기서는 32바이트를 얻으므로 32 * 8, 즉 256비트를 요청한다. 해시 이름도 각각 sha256과 SHA-256으로 지정한다.
앞의 파일 끝에 다음 코드를 붙이고 같은 명령으로 다시 실행하면, 같은 IKM·salt·authInfo를 쓴 두 API의 원시 결과를 비교할 수 있다.
const { subtle } = webcrypto;
const baseKey = await subtle.importKey(
'raw', ikm, 'HKDF', false, ['deriveBits']);
const webKey = Buffer.from(await subtle.deriveBits(
{ name: 'HKDF', hash: 'SHA-256', salt, info: authInfo },
baseKey, keyBytes * 8));
console.log('WebCrypto bytes:', webKey.length);
console.log('Node/WebCrypto equal:', auth.equals(webKey));
앞선 출력 뒤에 다음 두 줄이 붙었다. importKey에는 파생 전 IKM을 넣었으며, Node에서 먼저 만든 auth 키를 다시 파생한 것이 아니다.
WebCrypto bytes: 32
Node/WebCrypto equal: true
두 결과 모두 32바이트인데 일치하지 않는다면 길이만 확인하고 멈추지 않는다. 양쪽의 해시, 디코딩을 마친 IKM·salt, info 바이트를 함께 대조한다. 이 실행은 Node 안에 있는 WebCrypto와의 비교다. 브라우저에서 실행한 결과로 확대하지 않는다.
UTF-8로 정해도 같은 모양의 문자열이 같지는 않다
문자열을 바이트로 바꾸는 규칙도 info 계약에 들어간다. 위 예제는 Buffer.from의 encoding 인자 (새 탭에서 열림)를 utf8로 명시했다. 별도 비교에서 같은 note:v1:auth를 UTF-16LE로 바꾸면 파생 결과가 달라졌다. 양쪽 모두 UTF-8이어도 유니코드 표현이 다를 수 있다.
다음 코드를 같은 파일 끝에 덧붙이면, é 하나를 쓴 문자열과 e 뒤에 결합 악센트를 붙인 문자열의 info를 비교할 수 있다. 이스케이프를 사용해 복사할 때 두 표현을 구별한다.
const composed = utf8('note:v1:auth:\u00e9');
const decomposed = utf8('note:v1:auth:e\u0301');
console.log('info bytes equal:', composed.equals(decomposed));
console.log('derived keys equal:', derive(composed).equals(derive(decomposed)));
console.log('NFC text equal:',
composed.toString('utf8').normalize('NFC') ===
decomposed.toString('utf8').normalize('NFC'));
추가된 출력은 다음과 같다.
info bytes equal: false
derived keys equal: false
NFC text equal: true
정규화하면 같은 텍스트여도, 정규화하기 전 HKDF에 들어간 바이트는 달랐다. Node의 암호 API 문자열 주의사항 (새 탭에서 열림)도 문자 표현을 자동으로 정규화하지 않는다고 설명한다. 그렇다고 수신값에 무조건 normalize()나 trim()을 적용해서는 안 된다. 정규화 여부와 시점은 양쪽이 따르는 규약으로 정해야 한다.
이 예제의 인증용 계약은 SHA-256, 위의 32바이트 IKM과 16바이트 salt, UTF-8 note:v1:auth 그대로, 출력 32바이트다. info 끝에 줄바꿈·공백·NUL을 덧붙이지 않는다. 실제 연동 문서에도 용도별 문자열과 인코딩을 이렇게 명시하고, 필요하면 공개 합성 입력의 기대 결과를 함께 두면 구현 간 차이를 찾기 쉽다. 실제 비밀 IKM이나 파생 키를 로그에 남겨 비교하는 방식은 쓰지 않는다.
직접 확인한 범위는 이 합성 입력들의 재현성, info 차이에 따른 결과, Node의 두 API 사이 일치와 작은 HMAC 비교다. 파생 키의 일반적인 보안 수준, 원본 키의 엔트로피, 실제 서비스의 프로토콜 안전성은 이 실험으로 검증하지 않았다.