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

빌드노트

Node ECDH computeSecret이 상대 공개키를 거절할 때

Node ECDH의 invalid public key는 곡선 불일치뿐 아니라 손상된 공개점과 잘못된 디코딩에서도 나타난다. 같은 길이의 공개키를 직접 비교하고, 곡선·공개점 표현·전송 인코딩을 송수신 규약에 맞춰 확인한다.

Node.js에서 상대 공개키를 받은 뒤 ecdh.computeSecret(peerPublicKey)가 ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY를 던진다면, 먼저 양쪽이 쓰기로 정한 곡선·공개점 표현·전송 인코딩이 같은지 확인한다. 바이트를 받았고 길이가 예상값과 같아도, 그 바이트가 내 ECDH 객체의 곡선에서 유효한 공개점을 나타낸다는 보장은 없다.

Node.js 22.23.2의 computeSecret 문서 (새 탭에서 열림)는 상대 공개키가 해당 곡선 위의 점이 아니면 이 오류를 던진다고 설명한다. 다만 오류 코드만 보고 “상대가 다른 곡선을 썼다”고 확정할 수는 없다. 아래 합성 입력 비교에서는 곡선 불일치, 좌표 손상, 잘못된 문자열 디코딩이 모두 같은 코드로 거절됐다.

공개키를 만든 쪽과 읽는 쪽의 규약부터 대조한다

기존 코드의 createECDH(curveName) 호출과 상대의 키 생성 설정을 나란히 확인한다. “둘 다 256비트”라는 설명으로는 부족하다. 이 글에서 비교한 prime256v1과 secp256k1은 서로 다른 곡선이다. 내 환경에서 지원하는지는 getCurves()로 조회할 수 있지만, 지원 목록에 있다는 이유로 연동 곡선을 바꾸지는 않는다. createECDH의 곡선 선택 (새 탭에서 열림)

다음으로 수신값이 어떤 층의 표현인지 확인한다.

  • 공개점 표현: getPublicKey()로 얻는 EC 점인지, PEM이나 SPKI DER 같은 공개키 컨테이너 전체인지 구분한다. 압축형과 비압축형 중 무엇을 보내기로 했는지도 확인한다.
  • 전송 인코딩: 점을 hex나 Base64 문자열로 보냈다면 수신 코드도 그 규칙으로 바이트를 복원해야 한다. hex 문자를 UTF-8 바이트로 바꾸면 원래 공개점과 다른 입력이 된다.
  • 유효한 점: 곡선과 표현을 맞춰도 좌표가 손상됐다면 계산을 거절해야 한다. 길이 검사는 이 조건을 대신하지 못한다.

RFC 5480의 공개키 구조 (새 탭에서 열림)는 곡선 매개변수가 들어가는 알고리즘 필드와 공개점 필드를 구분한다. 같은 문서의 2.2절 (새 탭에서 열림)은 점의 첫 바이트로 압축 여부를 나타내는 형식을 설명한다. 이 점 표현 자체에 곡선 이름이 붙는 것은 아니다. computeSecret에 컨테이너 전체를 점처럼 넘기거나, 오류를 없애려고 헤더·앞부분을 임의로 잘라내지 않는다. 먼저 상대가 제공하는 형식과 자신이 호출하는 API의 입력을 맞춰야 한다.

같은 곡선의 두 키를 정상 대조로 둔다

다음 코드는 지원 여부를 확인하고, 같은 곡선에서 독립적으로 생성한 두 키의 공유 결과가 같은지 비교한다. 이어서 같은 상대 공개점을 압축형 또는 hex 문자열로 전달해 비교한다. Node.js 22가 설치된 빈 폴더에 ecdh-demo.cjs로 저장한 뒤 node ecdh-demo.cjs를 실행한다. 별도 패키지는 필요 없고, 개인키와 공유 결과의 값은 출력하지 않는다.

const { createECDH, getCurves } = require('node:crypto');
const curves = getCurves();
for (const name of ['prime256v1', 'secp256k1']) {
  console.log('curve support:', name, curves.includes(name));
  if (!curves.includes(name)) throw new Error(`Unsupported curve: ${name}`);
}

const alice = createECDH('prime256v1');
const bob = createECDH('prime256v1');
alice.generateKeys();
bob.generateKeys();
const bobPublic = bob.getPublicKey();
const shared = alice.computeSecret(bobPublic);
console.log('same curve:',
  shared.equals(bob.computeSecret(alice.getPublicKey())));
console.log('compressed peer:', shared.equals(
  alice.computeSecret(bob.getPublicKey(undefined, 'compressed'))));
const peerHex = bobPublic.toString('hex');
console.log('hex peer:', shared.equals(alice.computeSecret(peerHex, 'hex')));

Windows x64, Node.js v22.23.2, OpenSSL 3.5.7의 FIPS 비활성 환경에서 실제 실행한 출력이다.

curve support: prime256v1 true
curve support: secp256k1 true
same curve: true
compressed peer: true
hex peer: true

same curve: true는 서로 다른 개인키를 가진 두 객체가 상대 공개키로 계산한 결과가 일치했다는 뜻이다. compressed peer도 같았으므로, 이 환경의 computeSecret은 같은 점의 압축형 입력도 받아들였다. 압축형이라는 이유만으로 잘못된 공개키로 판단할 수 없다. getPublicKey의 표현 옵션 (새 탭에서 열림)

hex peer에서는 문자열을 넘기는 두 번째 인자에 'hex'를 명시했다. 이 인자는 상대 공개키 문자열을 읽는 인코딩이다. 이미 점의 바이트를 담은 Buffer라면 첫 계산처럼 그대로 넘긴다. 문자열과 Buffer를 처리하는 수신 경계를 확인하면, 곡선 설정을 바꾸기 전에 잘못된 디코딩을 찾을 수 있다.

지원 여부가 false라면 이 예제를 그대로 비교할 준비가 되지 않은 것이다. Node·OpenSSL 빌드와 허용된 암호 설정을 확인한다. 정상 대조부터 오류가 나면 버전과 복사한 코드를 확인하고, 실제 연동의 곡선을 임의로 다른 값으로 교체하지 않는다.

길이를 유지해도 계산에 쓸 수 없는 점이 된다

앞의 파일 끝에 다음 코드를 붙이고 같은 명령으로 다시 실행한다. 이번에는 다른 곡선의 공개점, 비압축형의 첫 바이트만 남기고 좌표를 모두 0으로 덮은 복사본, hex 문자를 UTF-8로 바꾼 입력을 각각 넣는다. 한 비트만 바꾸면 여전히 유효한 점이 될 수도 있으므로, 여기서는 손상 조건을 좌표 전체 교체로 명확히 정했다.

const other = createECDH('secp256k1');
other.generateKeys();
const wrongCurve = other.getPublicKey();
const damaged = Buffer.from(bobPublic);
damaged.fill(0, 1); // 비압축형의 첫 바이트를 보존한다.
console.log('same public length:',
  bobPublic.length === wrongCurve.length && bobPublic.length === damaged.length);

for (const [label, peer] of [
  ['other curve', wrongCurve],
  ['zero coordinates', damaged],
  ['hex as utf8', Buffer.from(peerHex, 'utf8')],
]) {
  try {
    alice.computeSecret(peer);
    console.log(label + ': accepted');
  } catch (err) {
    console.log(label + ':', err.name, err.code);
  }
}
console.log('original again:', shared.equals(alice.computeSecret(bobPublic)));

앞의 정상 출력에 다음 결과가 붙었다. 오류의 name은 모두 Error였고, 메시지는 모두 Public key is not valid for specified curve였다. 이 문구는 해당 실행 환경의 관찰값이다.

same public length: true
other curve: Error ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY
zero coordinates: Error ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY
hex as utf8: Error ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY
original again: true

same public length: true인데 두 공개점은 거절됐다. 다른 곡선에서 생성한 키가 그 곡선에서는 정상이어도, 내 객체가 선택한 곡선의 입력으로 사용할 수 있다는 뜻은 아니다. 또 비압축형의 길이와 첫 바이트를 보존해도 좌표가 유효해지지는 않았다. hex as utf8까지 같은 오류를 냈으므로, 이 코드 하나로 전송 손상과 곡선 불일치를 구별할 수 없다.

원본을 다시 넣은 original again은 true였다. 이 비교에서 거절을 만든 것은 바꿔 넣은 입력이었다. 자기 환경에서 결과가 다르면 조건과 오류의 code·name을 확인한다. 다른 곡선 입력이 받아들여졌다는 결과만으로 상대와 같은 규약을 쓴다고 판단해서도 안 된다. API는 전달된 점을 자신이 선택한 곡선에 대해 해석하므로, 상대가 어떤 곡선을 사용했는지는 별도로 확인해야 한다.

거절된 입력을 성공할 때까지 변환하지 않는다

ECDH.convertKey는 지정한 곡선의 공개점 표현을 압축형·비압축형 등으로 바꾸는 API다. 다른 곡선의 키를 내 곡선의 키로 바꿔 주는 기능이 아니다. Node.js의 convertKey 설명 (새 탭에서 열림)처럼 이미 합의한 곡선과 유효한 점을 전제로 표현을 맞춰야 한다. 위 정상 대조에서는 압축형을 변환 없이 전달해도 같은 결과를 얻었다.

실제 수신 코드에서는 약속한 형식으로 디코딩한 뒤 computeSecret을 호출하고, 예외가 나면 그 키 교환을 거절한다. 예제의 반복문은 여러 실패 조건을 비교하기 위한 것이다. 서비스에서 예외를 잡은 뒤 다른 곡선이나 디코딩 방식을 차례로 시도해 통과시키는 흐름으로 옮기지 않는다. 정상 입력도 거절된다면 송신 측의 곡선 설정, 보내는 공개점 표현, 수신 직전 디코딩을 함께 고친다. 개인키나 공유 결과를 로그로 남겨 비교할 필요는 없다.

직접 확인한 범위는 이 Node·OpenSSL 환경에서 새 합성 키로 실행한 원시 ECDH 계산과 입력 거절이다. PEM·SPKI 파싱과 Base64 수신 경로, 다른 버전·FIPS 환경·외부 구현과의 통신은 검증하지 않았다. 두 결과가 일치했다는 관찰은 상대의 신원 인증이나 완성된 보안 프로토콜의 안전성을 입증하지 않는다. 인증과 키 파생을 포함한 실제 프로토콜의 요구사항은 별도로 충족해야 한다.

← 목록으로 돌아가기