원문 캡처 · github.com
원문 캡처 · github.com

Correct Hooks는 웹훅 인증을 위한 비대칭 서명, 검증, 키 관리를 제공하는 여러 언어용 라이브러리다. 제작자와 소비자가 모두 비대칭 서명을 적용하기 쉽게 만드는 것을 목표로 하며, 현재 초기 개발 단계지만 사용할 수 있다.

웹훅 제작자가 흔히 쓰는 HMAC 같은 대칭 서명은 서명과 검증에 같은 비밀 키를 사용한다. 따라서 제작자는 사용자마다, 실제 운영에서는 웹훅을 보내는 엔드포인트마다 공유 비밀 키를 관리해야 한다. 고객이 늘수록 키 관리가 복잡해지고 보안과 서비스 품질에도 부담이 생긴다고 프로젝트 측은 설명한다.

비대칭 서명에서는 제작자가 비밀 서명 키 하나를 비밀 관리자에 보관하고, 소비자는 제작자가 공개 엔드포인트로 제공하는 공개 검증 키를 가져와 사용할 수 있다. 키를 교체할 때는 공개 검증 키 세트를 갱신하면 소비자가 새 키를 자동으로 가져오므로 서비스 중단 없이 교체할 수 있다는 설명이다. 다만 키 형식과 배포, 서명·검증 절차를 직접 구현하는 일은 대칭 서명보다 복잡하다. Correct Hooks는 그 과정을 라이브러리로 묶었다.

현재 지원 언어는 TypeScript, Python, Rust다. Java, Go, C# 지원을 계획하고 있으며 다른 언어도 계속 검토할 예정이다. 아직 라이브러리가 없는 언어를 위한 커뮤니티 구현도 지원한다. 기존 구현을 참고할 수 있고, 여러 언어에서 함께 쓰는 테스트 스위트와 구현 조언을 제공할 계획이며 라이브러리 명세도 작성할 예정이다.

설치와 기본 사용

TypeScript 패키지는 다음 명령으로 설치한다.

text
npm i @handlewithcare/correct-hooks 

Python과 Rust는 각각 다음 명령으로 설치한다.

text
pip install correct_hooks 
text
cargo add correct_hooks 

웹훅 제작자는 먼저 비밀 서명 키를 생성하고 JSON으로 직렬화해 비밀 관리자에 보관한다. 키 내용은 불투명한 데이터로 취급해야 하며 직접 수정해서는 안 된다. 소비자에게는 비밀 키 목록으로 만든 공개 검증 키 세트를 JSON으로 제공한다. 이를 공개 GET 엔드포인트에 게시하고 URL을 소비자에게 알려야 한다. 처음에는 엔드포인트에서 직접 제공할 수 있고, 중간 규모 이상 배포에서는 CDN을 둘 수 있다.

TypeScript에서 키를 만들고 공개 키 세트를 생성하는 예시는 다음과 같다.

typescript
import { generateSecretSigningKey } from "@handlewithcare/correct-hooks";

const secretSigningKey = generateSecretSigningKey();
const secretSigningKeyJson = JSON.stringify(secretSigningKey);
typescript
import { createPublicVerifyingKeySet } from "@handlewithcare/correct-hooks";

const publicVerifyingKeySet = createPublicVerifyingKeySet([secretSigningKey]);
const publicVerifyingKeySetJson = JSON.stringify(publicVerifyingKeySet);

Python 예시는 다음과 같다.

python
from correct_hooks import generate_secret_signing_key

secret_signing_key = generate_secret_signing_key()
secret_signing_key_json = secret_signing_key.model_dump_json()
python
from correct_hooks import create_public_verifying_key_set

public_verifying_key_set = create_public_verifying_key_set([secret_signing_key])
public_verifying_key_set_json = public_verifying_key_set.model_dump_json()

Rust 예시는 다음과 같다.

rust
use correct_hooks::generate_secret_signing_key;

let secret_signing_key = generate_secret_signing_key();
let secret_signing_key_json = serde_json::to_string(&secret_signing_key).unwrap();
rust
use correct_hooks::create_public_verifying_key_set;

let public_verifying_key_set = create_public_verifying_key_set(&vec![secret_signing_key]).unwrap();
let public_verifying_key_set_json = serde_json::to_string(&public_verifying_key_set);

제작자는 요청 데이터를 서명하고 반환된 값을 correct-hooks-signature-components와 correct-hooks-signature 헤더에 넣는다. 소비자는 제작자의 공개 검증 키 세트 URL로 검증기를 만든다. 백엔드 인스턴스가 시작될 때 한 번 생성해 해당 인스턴스의 모든 요청에 재사용하도록 안내한다. 검증에서 오류가 나면 서명이 유효하지 않은 것이므로 요청을 즉시 거부해야 한다.

TypeScript 서명·검증 예시는 다음과 같다.

typescript
import { sign } from "@handlewithcare/correct-hooks";

const signatureHeaders = sign({
 webhookId,
 recipientId,
 targetUrl,
 method,
 requestBody,
 secretSigningKey,
});
const { correctHooksSignatureComponents, correctHooksSignature } = signatureHeaders;
typescript
import { Verifier } from "@handlewithcare/correct-hooks";

const verifier = new Verifier({
 recipientId,
 publicVerifyingKeySetUrl,
});
typescript
let verifiedData;
try {
 verifiedData = await verifier.verify({
 targetUrl,
 method,
 correctHooksSignatureComponents,
 correctHooksSignature,
 requestBody,
 });
} catch {
 return StatusCode.FORBIDDEN;
}
const { webhookId } = verifiedData 

Python 서명·검증 예시는 다음과 같다.

python
from correct_hooks import (sign, SignArgs)

signature_headers = sign(SignArgs(
 webhook_id=webhook_id,
 recipient_id=recipient_id,
 target_url=target_url,
 method=method,
 request_body=request_body,
 secret_signing_key=secret_signing_key,
))
correct_hooks_signature_components = signature_headers.correct_hooks_signature_components
correct_hooks_signature = signature_headers.correct_hooks_signature
python
from correct_hooks import (Verifier, VerifierConfig)

verifier = Verifier(VerifierConfig(
 recipient_id=recipient_id,
 public_verifying_key_set_url=AnyUrl(public_verifying_key_set_url),
))
python
from correct_hooks import VerifyArgs

try:
 verified_data = await verifier.verify(
 target_url=target_url,
 method=method,
 correct_hooks_signature_components=signature_headers.correct_hooks_signature_components,
 correct_hooks_signature=signature_headers.correct_hooks_signature,
 request_body=request_body,
 )
except Exception:
 return HTTPStatus.FORBIDDEN

Rust 서명·검증 예시는 다음과 같다.

rust
use correct_hooks::{SignArgs, sign};

let signature_headers = sign(
 SignArgs {
 webhook_id,
 recipient_id,
 target_url,
 method,
 request_body, 
 secret_signing_key,
 }
).unwrap();
let correct_hooks_signature_components = &signature_headers.correct_hooks_signature_components;
let correct_hooks_signature = &signature_headers.correct_hooks_signature;
rust
use correct_hooks::{Verifier, VerifierConfig};

let verifier = Verifier::new(
 VerifierConfig {
 recipient_id,
 public_verifying_key_set_url,
 signature_ttl_seconds: None,
 public_verifying_key_set_ttl_seconds: None,
 }
);
rust
use correct_hooks::VerifyArgs;

let verify_result = verifier.verify(VerifyArgs{
 target_url,
 method,
 correct_hooks_signature_components,
 correct_hooks_signature,
 request_body,
}).await;

if verify_result.is_err() {
 return StatusCode::FORBIDDEN;
}
let verified_data = verify_result.unwrap();

구현 세부사항은 언어별 SignArgs, VerifyArgs(TypeScript), SignArgs(Python), VerifyArgs(Python), SignArgs(Rust), VerifyArgs(Rust)에서 확인할 수 있다. 설정 항목은 VerifyConfig를 참고한다.

키 교체와 마이그레이션

키 교체 절차는 새 키를 만든 뒤, 기존 키와 새 키를 모두 포함한 공개 검증 키 세트를 게시하는 것에서 시작한다. 공개 키 세트를 캐시나 CDN 뒤에서 제공한다면 캐시를 비운다. 이후 새 비밀 키로 요청에 서명하고, 새 키만 담은 공개 검증 키 세트를 다시 게시한 뒤 캐시를 비운다. 소비자의 검증기는 새 공개 키 세트를 자동으로 가져온다.

Correct Hooks는 기존 웹훅 인증 방식과 병행할 수 있도록 설계됐다. 웹훅 요청에 Correct Hooks 헤더를 추가해 기존 시스템을 중단 없이 전환할 수 있다고 프로젝트 측은 설명한다.

라이브러리 업그레이드 때는 라이브러리 버전과 서명 버전을 구분해야 한다. 라이브러리는 시맨틱 버전을 사용하며, 메이저 업데이트에서 호환성이 깨질 수 있다. 1.0 전에는 마이너 업데이트에서도 호환성이 깨질 수 있다. 서명 버전은 보통 SIGNATURE_VERSION 상수에 저장된다. 라이브러리에 호환성을 깨는 변경이 있어도 서명 버전이 바뀌지 않을 수 있지만, 서명 버전 변경은 호환성을 깨는 변경이다.

라이브러리는 기본적으로 현재 서명 버전을 사용해 서명하지만 이전 버전으로 서명하는 기능도 제공한다. 서명 버전이 바뀌는 업그레이드에서는 새 버전과 이전 버전으로 서명을 모두 만들고, 두 서명을 공백으로 구분해 Correct Hooks 헤더에 넣을 수 있다. 검증기는 자신이 지원하는 서명 버전 이하를 검증하며, 가능한 가장 높은 버전을 먼저 시도한다. 이에 따라 소비자는 원하는 시점에 라이브러리를 업데이트하고 제작자는 구버전 지원을 제거하기 전까지 여러 서명 버전을 함께 지원할 수 있다. 현재 서명 버전은 하나뿐이라 이 호환성 기능은 아직 가정적인 상황에 해당한다고 프로젝트 측은 밝혔다.

프로젝트 측은 엔드포인트마다 고유한 비밀 키가 필요하지 않다고 설명한다. 서명에는 요청이 웹훅 제작자가 서명했고 해당 소비자와 엔드포인트를 대상으로 전송됐는지 확인하는 데 필요한 정보가 포함되므로, 제작자는 모든 웹훅에 비밀 키 하나를 사용할 수 있다는 주장이다.

성능과 보안상 고려사항

프로젝트 측은 Correct Hooks가 사용하는 ed25519가 빠르다고 설명한다. 노트북에서 5KiB 페이로드를 서명했을 때 Rust는 27μs, 즉 CPU 스레드 하나에서 초당 3만 7,000회였고 TypeScript는 초당 2만 8,000회였다고 밝혔다. 이는 프로젝트 측의 자체 측정이며, HMAC이 서명과 검증에서 매우 빠르다는 점도 함께 언급했다.

서명과 키 관리를 쉽게 해도 웹훅 시스템의 보안·서비스 품질을 위한 다른 작업은 필요하다. HTTPS 강제, SSRF 공격 방어, 신뢰할 수 있는 재시도, 처리 실패 메시지 관리 등이 포함된다. 프로젝트는 시스템 설계 시 Standard Webhooks 명세의 운영 고려사항을 참고하라고 안내한다.

지원하지 않는 언어를 쓰는 소비자는 해당 언어 지원을 요청하거나 직접 검증 절차를 구현할 수 있다. 프로젝트는 기존 구현과 여러 언어용 테스트 스위트를 참고해 커뮤니티 라이브러리를 만드는 방법도 제안한다. 보안 취약점은 GitHub Security Advisories로 신고할 수 있으며, 신고는 처리하고 제보자에게 공을 돌리겠다고 밝혔다.