Skip to content

Webhook HMAC 서명 검증

logi 의 X-Logi-Signature 헤더는 두 가지 형식이 공존 합니다. 어떤 이벤트가 어떤 형식으로 오는지는 Webhook 연동 — 요청 형식 참고. RP verifier 는 반드시 두 형식 모두 받아들여야 합니다.

형식헤더 값 예시출처secret
Legacysha256=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= 필드.

검증 순서

  1. X-Logi-Signature 형식 분기
    • 값에 , 또는 t= 가 포함되면 → PLAN-L (t=…,kid=…,v1=…) 으로 파싱
    • sha256= 로 시작하면 → legacy
  2. Timestamp 범위 확인 — 현재 시각과 ±5분 이내 (replay 방어)
    • PLAN-L: t=
    • Legacy: X-Logi-Timestamp 헤더 값
  3. Signature 재계산 후 상수시간 비교
    • PLAN-L: kid 로 키 조회 → HMAC-SHA256(secret_for_kid, raw_body)v1= 와 비교
    • Legacy: HMAC-SHA256(webhook_secret, raw_body)sha256= 의 hex 와 비교

두 형식 동시 지원 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)
end
python
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 를 환경변수에 저장 후 코드에서 검증

Identity가 제품의 신뢰를 만듭니다.