Webhook HMAC 서명 검증
logi 의 X-Logi-Signature 헤더는 두 가지 형식이 공존 합니다. 어떤 이벤트가 어떤 형식으로 오는지는 Webhook 연동 — 요청 형식 참고. RP verifier 는 반드시 두 형식 모두 받아들여야 합니다.
| 형식 | 헤더 값 예시 | 출처 | secret |
|---|---|---|---|
| Legacy | sha256=a3d9f0... | WebhookDispatchJob (구 4개 이벤트) | 단일 webhook_secret |
| PLAN-L (canonical) | t=1735000000,kid=whk_2025q4_a1,v1=a3d9f0... | Logi::Webhooks::DeliveryJob (user.merged 등 신규) | kid 로 lookup 한 키 |
두 형식 모두 본문에 대한 HMAC-SHA256 이지만 타임스탬프 위치가 다릅니다 — legacy 는 별도 X-Logi-Timestamp 헤더, PLAN-L 은 서명 헤더 안의 t= 필드.
검증 순서
X-Logi-Signature형식 분기- 값에
,또는t=가 포함되면 → PLAN-L (t=…,kid=…,v1=…) 으로 파싱 sha256=로 시작하면 → legacy
- 값에
- Timestamp 범위 확인 — 현재 시각과 ±5분 이내 (replay 방어)
- PLAN-L:
t=값 - Legacy:
X-Logi-Timestamp헤더 값
- PLAN-L:
- Signature 재계산 후 상수시간 비교
- PLAN-L:
kid로 키 조회 →HMAC-SHA256(secret_for_kid, raw_body)→v1=와 비교 - Legacy:
HMAC-SHA256(webhook_secret, raw_body)→sha256=의 hex 와 비교
- PLAN-L:
두 형식 동시 지원 verifier
ts
import crypto from "node:crypto";
// keyResolver(kid) → Buffer/Uint8Array secret. 단일 키만 운영 중이면
// `(kid) => process.env.LOGI_WEBHOOK_SECRET` 식으로 무시해도 됩니다.
export function verifyLogiWebhook(req, { legacySecret, keyResolver }) {
const sigHeader = req.header("X-Logi-Signature") ?? "";
const raw = req.rawBody; // ← 반드시 파싱 전 원본 바이트
// 1. Format split
if (sigHeader.includes(",") || sigHeader.startsWith("t=")) {
// PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
const fields = Object.fromEntries(
sigHeader.split(",").map(p => p.split("=").map(s => s.trim()))
);
if (!fields.t || !fields.kid || !fields.v1) throw new Error("malformed sig");
if (Math.abs(Date.now() / 1000 - Number(fields.t)) > 300) throw new Error("replay");
const secret = keyResolver(fields.kid);
if (!secret) throw new Error(`unknown kid: ${fields.kid}`);
const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(fields.v1, "hex"), b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad sig");
return;
}
// Legacy: sha256=<hex> + X-Logi-Timestamp header
const ts = Number(req.header("X-Logi-Timestamp") ?? "0");
if (Math.abs(Date.now() / 1000 - ts) > 300) throw new Error("replay");
const sig = sigHeader.replace("sha256=", "");
const expected = crypto.createHmac("sha256", legacySecret).update(raw).digest("hex");
const a = Buffer.from(sig, "hex"), b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad sig");
}ruby
require "openssl"
def verify_logi!(request, legacy_secret:, key_resolver:)
sig_header = request.headers["X-Logi-Signature"].to_s
raw = request.raw_post
if sig_header.include?(",") || sig_header.start_with?("t=")
# PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
fields = sig_header.split(",").to_h { |p| p.split("=", 2).map(&:strip) }
raise "malformed sig" unless fields["t"] && fields["kid"] && fields["v1"]
raise "replay" if (Time.current.to_i - fields["t"].to_i).abs > 300
secret = key_resolver.call(fields["kid"])
raise "unknown kid: #{fields['kid']}" unless secret
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw)
raise "bad sig" unless ActiveSupport::SecurityUtils.secure_compare(fields["v1"], expected)
return
end
# Legacy
ts = request.headers["X-Logi-Timestamp"].to_i
raise "replay" if (Time.current.to_i - ts).abs > 300
sig = sig_header.sub("sha256=", "")
expected = OpenSSL::HMAC.hexdigest("SHA256", legacy_secret, raw)
raise "bad sig" unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)
endpython
import hmac, hashlib, time
def verify_logi(headers, raw_body, legacy_secret, key_resolver):
sig_header = headers.get("X-Logi-Signature", "")
if "," in sig_header or sig_header.startswith("t="):
# PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
fields = dict(part.strip().split("=", 1) for part in sig_header.split(","))
if not all(k in fields for k in ("t", "kid", "v1")):
raise ValueError("malformed sig")
if abs(time.time() - int(fields["t"])) > 300:
raise ValueError("replay")
secret = key_resolver(fields["kid"])
if not secret:
raise ValueError(f"unknown kid: {fields['kid']}")
expected = hmac.new(secret.encode() if isinstance(secret, str) else secret,
raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(fields["v1"], expected):
raise ValueError("bad sig")
return
# Legacy
ts = int(headers.get("X-Logi-Timestamp", "0"))
if abs(time.time() - ts) > 300:
raise ValueError("replay")
sig = sig_header.replace("sha256=", "")
expected = hmac.new(legacy_secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
raise ValueError("bad sig")주의: 반드시 raw body (JSON 파싱 전 원본 바이트)로 검증. 파싱 후 직렬화하면 공백/순서가 달라져 서명 불일치.
kid 키 조회 (PLAN-L 전용)
PLAN-L 형식의 kid 는 logi 가 회전 가능한 webhook signing key 의 식별자입니다. RP 는 다음 두 가지 방법 중 하나로 kid → secret 매핑을 유지합니다.
- Polling:
GET /api/v1/webhook_signing_keys를 주기적으로 호출해 활성 키 + grace 윈도우 안의 구 키를 캐시 - Push:
webhook_key.compromised이벤트 수신 시 즉시 재조회
회전 절차와 grace 윈도우는 Webhook Key Rotation 참고.
Legacy 단일-secret rotation
Legacy 경로는 키 ID 없이 단일 webhook_secret 을 회전합니다.
bash
# 회전
PAK="logi_pak_..."
curl -X POST -H "Authorization: Bearer $PAK" \
https://api.1pass.dev/api/v1/applications/$APP_ID/rotate_webhook_secret \
| jq -r '.webhook_secret'
# → 평문이 1회 출력됨. 즉시 secret manager에 저장.회전 후에는 위 검증 코드의 legacySecret 값을 새 plaintext 로 교체하세요. 회전 시점부터 새 서명이 즉시 적용되며 grace 윈도우 없음 — 회전 직후 잠깐 양 secret 이 모두 유효하기를 원한다면 PLAN-L outbox 경로로 마이그레이션 권장.
Deprecation 헤더 처리
기존 앱이 webhook secret 을 회전하지 않은 경우 logi 는 BCrypt fallback 으로 서명합니다 (legacy 경로 한정). 이 경우 다음 헤더가 함께 전송됩니다:
http
X-Logi-Secret-Deprecated: true
Deprecation: @1925000000- 수신 측은 WARN 레벨로 로깅 권장 — 검증은 계속 동작하나 stable plaintext 가 아니므로 서명 재현 불가
- 즉시 개발자 포털 에서
rotate_webhook_secret호출 → 응답으로 내려온 plaintext 를 환경변수에 저장 후 코드에서 검증