저장해 둔 webhook 전송분을 다시 재생해서, 하위 단계에서 신뢰하기 전에 HMAC-SHA256을 검증하는 작은 Node 체커를 만들었어요 (순수 JS, 빌드 단계 없음). 세 개 중 두 개는 통과해요. billing-partner의 evt_2044는 실패하는데, 같은 파트너의 evt_2051은 같은 secret으로 통과해요.
$ node main.js
staging evt_1001 ok
billing-partner evt_2044 FAIL (digest)
billing-partner evt_2051 ok
두 secret 모두 지난주에 교체했어서 처음엔 오래된 secret 탓을 했어요. 그런데 그러면 evt_2051도 통과하지 못했을 거예요. 제가 보기에 유일한 차이는 2044의 고객 이름이에요. 악센트가 들어간 Zoë Ramírez요.
그래서 payload가 createHmac에 들어가는 과정 어딘가에서 utf8과 latin1 문제가 아닐까 의심했어요. update()에 넘기는 인코딩을 바꿔 봤는데 전혀 달라지지 않았어요.
lib/verify.js의 핵심 부분이에요:
const body = JSON.parse(delivery.raw);
// sign the payload, same as the partner docs describe
const payload = JSON.stringify(body);
const expected = crypto
.createHmac("sha256", secret)
.update(payload, "utf8")
.digest("hex");
파트너 문서에는 서명이 "the JSON payload"에 대한 거라고 나와 있어요 (아마 맞을 거예요). 그래서 이 외에 뭘 해시해야 하는지 모르겠어요. 제가 뭘 놓치고 있을까요? 그리고 왜 그 이벤트만 그럴까요?
(toleranceSeconds 재생 검사는 TODO니까 무시해 주세요.)
영어에서 번역·원문 보기I wrote a small Node checker (plain JS, no build step) that replays saved webhook deliveries and verifies the HMAC-SHA256 before anything downstream trusts them. Two of three pass. evt_2044 from billing-partner fails, but evt_2051 from the same partner passes with the same secret.
$ node main.js
staging evt_1001 ok
billing-partner evt_2044 FAIL (digest)
billing-partner evt_2051 ok
Both secrets were rotated last week, so I first blamed a stale secret. But then evt_2051 wouldn't have passed either. The only difference I can see is the customer name in 2044: Zoë Ramírez, with accents.
So I suspected utf8 vs latin1 somewhere in how the payload goes into createHmac. I tried changing the encoding passed to update(). No difference at all.
The core of lib/verify.js:
const body = JSON.parse(delivery.raw);
// sign the payload, same as the partner docs describe
const payload = JSON.stringify(body);
const expected = crypto
.createHmac("sha256", secret)
.update(payload, "utf8")
.digest("hex");
The partner docs say the signature is over "the JSON payload" (I'm fairly sure), so I don't see what else I should be hashing. What am I missing? And why only that one event?
(The toleranceSeconds replay check is a TODO, ignore it.)