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

빌드노트

RSA-PSS 키와 데이터가 같은데 검증은 false다

Node.js WebCrypto의 RSA-PSS는 서명과 검증의 saltLength가 다르면 같은 키·데이터에서도 false를 반환한다. 32바이트와 0바이트 salt를 교차 검증하고 메시지 변조 거부를 확인한다. 해시는 키 생성·가져오기에서, salt 길이는 호출에서 프로토콜에 맞춰 명시한다.

Node.js WebCrypto에서 RSA-PSS 서명을 검증할 때, 같은 키 쌍과 같은 데이터를 써도 subtle.verify()가 false를 반환할 수 있다. 서명 생성과 검증에 전달한 saltLength가 다르면 그렇다. 키와 메시지만 대조하지 말고, 프로토콜이 정한 해시와 salt 길이를 양쪽 코드에서 확인해야 한다.

아래 실험에서는 SHA-256 키로 32바이트 salt를 사용해 만든 서명을, 검증 길이 32와 0으로 비교한다. 실제 서비스의 장애 기록이 아니라 새 합성 키로 원인을 분리한 재현이다. false라는 결과 하나로 모든 검증 실패의 원인을 salt 길이로 확정할 수는 없다.

해시는 키에, saltLength는 서명·검증 호출에 있다

기존 코드를 확인한다면 subtle.sign()과 subtle.verify()에 넘기는 첫 번째 인자부터 비교한다. 둘 다 name: 'RSA-PSS'여도 saltLength가 다를 수 있다. salt는 PSS 서명 생성에 사용하는 임의 바이트열이며, 이 옵션은 그 길이를 지정한다. Node.js v22.23.2의 RsaPssParams 문서 (새 탭에서 열림)는 단위를 바이트(bytes)로 설명한다. saltLength: 32는 32바이트이고, saltLength: 0은 0바이트다.

해시는 다른 위치에 있다. generateKey()로 키를 만들거나 importKey()로 가져올 때 hash를 지정한다. 검증에 실제로 넘기는 공개 키가 publicKey라면 publicKey.algorithm.hash.name으로 적용된 해시를 확인할 수 있다. 서명 측 개인 키도 같은 방식으로 확인한다. W3C의 RSA-PSS 연산 정의 (새 탭에서 열림)는 서명과 검증 모두 키에 연결된 해시와 호출에 지정된 saltLength를 사용하도록 정한다. verify()의 옵션에 hash를 덧붙여 이미 가져온 키의 해시를 바꾸려 하지 않는다.

프로토콜이 SHA-256과 32바이트 salt를 요구한다면, 양쪽 키 생성·가져오기에 hash: 'SHA-256'을 지정하고 서명·검증 호출에는 saltLength: 32를 명시한다. 이 글의 32를 모든 RSA-PSS 연동의 공통값으로 적용하지는 않는다. 사용할 값은 해당 프로토콜에서 확인한다.

같은 서명에서 검증 길이만 바꿔 본다

다음 코드는 같은 키 쌍과 데이터를 유지하면서 32바이트 서명과 0바이트 서명을 각각 검증한다. 마지막에는 32바이트 조건을 유지한 채 메시지만 바꿔 본다. Node.js 22가 설치된 환경에서 내용을 UTF-8 파일 pss-demo.mjs로 저장하고, 그 파일이 있는 폴더에서 node pss-demo.mjs를 실행한다. 외부 패키지는 필요 없으며 새 키는 메모리에서만 사용한다.

import { webcrypto } from 'node:crypto';
const { subtle } = webcrypto;
const { privateKey, publicKey } = await subtle.generateKey({
  name: 'RSA-PSS',
  modulusLength: 2048,
  publicExponent: new Uint8Array([1, 0, 1]),
  hash: 'SHA-256',
}, false, ['sign', 'verify']);
const data = new TextEncoder().encode('memo=demo&version=1');
const pss32 = { name: 'RSA-PSS', saltLength: 32 };
const pss0 = { name: 'RSA-PSS', saltLength: 0 };

const signature32 = await subtle.sign(pss32, privateKey, data);
console.log('sign32 verify32',
  await subtle.verify(pss32, publicKey, signature32, data));
console.log('sign32 verify0',
  await subtle.verify(pss0, publicKey, signature32, data));

const signature0 = await subtle.sign(pss0, privateKey, data);
console.log('sign0 verify0',
  await subtle.verify(pss0, publicKey, signature0, data));
console.log('sign0 verify32',
  await subtle.verify(pss32, publicKey, signature0, data));

const changedData = new TextEncoder().encode('memo=demo&version=2');
console.log('sign32 changed data',
  await subtle.verify(pss32, publicKey, signature32, changedData));
console.log('sign32 original again',
  await subtle.verify(pss32, publicKey, signature32, data));

2026년 10월 23일 Windows x64의 Node.js v22.23.2, OpenSSL 3.5.7에서 실행한 출력이다. 앞의 네 줄은 서명에 사용한 길이와 검증에 지정한 길이가 같은지 비교한다.

sign32 verify32 true
sign32 verify0 false
sign0 verify0 true
sign0 verify32 false
sign32 changed data false
sign32 original again true

첫 두 줄에서는 동일한 signature32를 그대로 두고 검증 길이만 바꿨다. 32에서는 성공하고 0에서는 실패했으므로, 이 비교의 차이는 키나 데이터 변경으로 설명할 수 없다. 이어지는 두 줄에서는 0바이트 salt로 새로 만든 서명이 0에서만 성공했다. 따라서 여기서 saltLength: 0은 어떤 길이든 받아들이는 자동 감지 설정이 아니다.

마지막 두 줄은 길이를 맞춘 뒤에도 메시지를 확인하는지 보여 준다. version=1을 version=2로 바꾸자 false였고, 원본 데이터는 다시 true였다. salt 길이를 맞추는 것으로 메시지 변조가 허용되지는 않았다. 예제는 차이를 보기 위해 실패 결과도 출력하지만, 실제 수신 처리에서는 false를 검증 실패로 처리하고 해당 메시지 처리를 중단해야 한다.

자기 코드에서 길이를 맞춘 조건까지 실패한다면 서명 측이 사용한 원본 바이트, 수신 서명의 디코딩 결과, 공개 키 선택과 해시를 확인한다. 이 예제에서는 TextEncoder가 만든 바이트를 그대로 전달했다. 실제 연동에서 JSON을 다시 직렬화하거나 줄바꿈을 바꾸면 같은 내용처럼 보여도 서명 대상 바이트가 달라질 수 있다. 성공하는 salt 길이를 찾으려고 여러 값을 순서대로 시도하거나 수신자가 보낸 길이를 그대로 채택하지 말고, 프로토콜에서 허용한 조건을 기준으로 고친다.

직접 실행한 범위는 위 버전의 webcrypto.subtle.sign()과 webcrypto.subtle.verify(), 새 2048비트 RSA-PSS 키 쌍, SHA-256, salt 길이 32와 0이다. 브라우저, 외부 서명 서비스, 운영 키·인증서와 전송 경로는 검증하지 않았다. 별도 API인 Node의 crypto.verify()에서 salt 길이를 자동으로 처리하는 옵션의 동작까지 이 결과로 일반화하지 않는다.

← 전체 글 보기