# Story 4.42: Webhook HMAC Signature Authentication

**Status:** Complete
**Priority:** Medium
**Depends on:** None

---

## Context / Background

The GPT-5.2 audit flagged a security concern with the webhook sink's authentication mechanism: the secret is sent directly as a header value, increasing exposure risk.

**Current state in `src/fapilog/plugins/sinks/webhook.py:84-87`:**

```python
async def _post(self, payload: Any) -> httpx.Response:
    headers = dict(self._config.headers)
    if self._config.secret:
        headers.setdefault("X-Webhook-Secret", self._config.secret)  # Secret in plaintext header
```

**Problem:**

Sending secrets directly in headers increases exposure risk via:
- Proxy server logs (many log headers by default)
- CDN/WAF request logging
- Network monitoring tools
- Accidental logging in receiving application

**Industry standard approach:**

GitHub, Stripe, and other webhook providers use HMAC signatures instead:
- Compute `HMAC-SHA256(secret, payload)`
- Send signature in header (e.g., `X-Hub-Signature-256`)
- Receiver computes same HMAC and compares
- Secret is never transmitted

---

## Scope (In / Out)

### In Scope

- Add HMAC signature option to WebhookSink
- Keep current `X-Webhook-Secret` for backward compatibility (with deprecation warning)
- Document both approaches with security recommendations
- Add receiver-side verification example

### Out of Scope

- Removing `X-Webhook-Secret` entirely (breaking change for later)
- Other auth mechanisms (OAuth, API keys)
- Timestamp-based replay protection (future enhancement)

---

## Acceptance Criteria

### AC1: HMAC Signature Option Available

**Description:** WebhookSink supports HMAC-SHA256 signature authentication.

**Validation:**

```python
from fapilog.plugins.sinks.webhook import WebhookSink, WebhookSinkConfig

config = WebhookSinkConfig(
    endpoint="https://example.com/webhook",
    secret="my-secret-key",
    signature_mode="hmac",  # New option
)
sink = WebhookSink(config=config)

# Headers will include:
# X-Fapilog-Signature-256: sha256=<hex-hmac-of-payload>
```

### AC2: Backward Compatible Default

**Description:** Default behavior unchanged; existing configs work but emit deprecation warning.

**Validation:**

```python
# Existing config still works
config = WebhookSinkConfig(
    endpoint="https://example.com/webhook",
    secret="my-secret",
    # No signature_mode = defaults to "header" (legacy)
)

# Warning emitted:
# "X-Webhook-Secret header mode is deprecated.
#  Use signature_mode='hmac' for HMAC-SHA256 signatures."
```

### AC3: Signature Verified by Receiver

**Description:** Documentation includes receiver-side verification example.

**Validation:**

```python
# In docs/user-guide/webhook-security.md

import hmac
import hashlib

def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# Usage in FastAPI:
@app.post("/webhook")
async def webhook(request: Request):
    body = await request.body()
    sig = request.headers.get("X-Fapilog-Signature-256")
    if not verify_webhook(body, sig, WEBHOOK_SECRET):
        raise HTTPException(401, "Invalid signature")
```

### AC4: Tests Cover Both Modes

**Description:** Unit tests verify both header and HMAC modes.

**Validation:**

```python
# test_webhook_signature.py

async def test_hmac_signature_mode():
    sink = WebhookSink(endpoint="...", secret="key", signature_mode="hmac")
    # Verify X-Fapilog-Signature-256 header present
    # Verify HMAC computation correct

async def test_header_mode_deprecated():
    with pytest.warns(DeprecationWarning):
        sink = WebhookSink(endpoint="...", secret="key")
```

---

## Implementation Notes

### File Changes

```text
src/fapilog/plugins/sinks/webhook.py (MODIFIED)
docs/user-guide/webhook-security.md (NEW)
tests/unit/test_webhook_signature.py (NEW)
```

### Config Changes

```python
from enum import Enum

class SignatureMode(str, Enum):
    HEADER = "header"  # Legacy: X-Webhook-Secret (deprecated)
    HMAC = "hmac"      # Recommended: X-Fapilog-Signature-256

class WebhookSinkConfig(BaseModel):
    endpoint: str
    secret: str | None = None
    signature_mode: SignatureMode = Field(
        default=SignatureMode.HEADER,
        description="Authentication mode: 'header' (deprecated) or 'hmac' (recommended)",
    )
    headers: dict[str, str] = Field(default_factory=dict)
    # ... rest unchanged
```

### Implementation

```python
import hashlib
import hmac as _hmac
import json
import warnings

async def _post(self, payload: Any) -> httpx.Response:
    headers = dict(self._config.headers)

    if self._config.secret:
        if self._config.signature_mode == SignatureMode.HMAC:
            # Compute HMAC-SHA256 of JSON payload
            body = json.dumps(payload, separators=(",", ":")).encode()
            signature = _hmac.new(
                self._config.secret.encode(),
                body,
                hashlib.sha256
            ).hexdigest()
            headers["X-Fapilog-Signature-256"] = f"sha256={signature}"
        else:
            # Legacy mode - deprecation warning
            warnings.warn(
                "X-Webhook-Secret header mode is deprecated. "
                "Use signature_mode='hmac' for HMAC-SHA256 signatures.",
                DeprecationWarning,
                stacklevel=2,
            )
            headers.setdefault("X-Webhook-Secret", self._config.secret)

    # ... rest of _post
```

---

## Tasks

### Phase 1: Implementation

- [ ] Add `SignatureMode` enum
- [ ] Add `signature_mode` to `WebhookSinkConfig`
- [ ] Implement HMAC signature computation
- [ ] Add deprecation warning for header mode

### Phase 2: Testing

- [ ] Test HMAC signature correctness
- [ ] Test deprecation warning emitted
- [ ] Test backward compatibility

### Phase 3: Documentation

- [ ] Create `docs/user-guide/webhook-security.md`
- [ ] Add receiver verification examples
- [ ] Update CHANGELOG

---

## Tests

### Unit Tests

- `tests/unit/test_webhook_signature.py`
  - `test_hmac_signature_computed_correctly`
  - `test_hmac_signature_in_header`
  - `test_header_mode_emits_deprecation`
  - `test_no_secret_no_signature`
  - `test_receiver_verification_example`

---

## Definition of Done

### Code Complete

- [ ] HMAC signature mode implemented
- [ ] Deprecation warning for header mode
- [ ] Backward compatible

### Quality Assurance

- [ ] All tests pass
- [ ] `ruff check` passes
- [ ] `mypy` passes

### Documentation

- [ ] Webhook security guide created
- [ ] Receiver examples provided
- [ ] CHANGELOG updated

---

## Risks / Rollback

### Risks

1. **Risk:** Breaking existing webhook receivers
   - **Mitigation:** Default to legacy mode; explicit opt-in for HMAC

2. **Risk:** HMAC computation performance overhead
   - **Mitigation:** SHA256 is fast; negligible for webhook payloads

### Rollback Plan

If issues occur:

1. Remove HMAC mode
2. Keep header mode without deprecation warning

---

## Related Stories

- **Related:** Story 5.6 - HTTP sink batching (similar sink patterns)

---

## Code Review

**Date:** 2026-01-16
**Reviewer:** Claude
**Verdict:** OK to PR

### Summary

Reviewed HMAC-SHA256 signature implementation for WebhookSink with deprecation warning for legacy header mode.

### AC Verification

| Criterion | Evidence |
|-----------|----------|
| AC1: HMAC Signature Option | `webhook.py:103-111` - HMAC computation, `X-Fapilog-Signature-256` header |
| AC2: Backward Compatible Default | `webhook.py:42-45` - defaults to HEADER; `webhook.py:113-120` - DeprecationWarning |
| AC3: Receiver Verification Docs | `webhook-security.md:51-60` - verify_signature with hmac.compare_digest |
| AC4: Tests Cover Both Modes | `test_webhook_signature.py:62-86` (HMAC), `test_webhook_signature.py:113-130` (deprecation) |

### Quality Gates

- [x] ruff check passed
- [x] ruff format passed
- [x] mypy passed
- [x] diff-cover 100%
- [x] No weak assertions
- [x] CHANGELOG updated

---

## Change Log

| Date       | Change        | Author |
|------------|---------------|--------|
| 2026-01-16 | Code review passed | Claude |
| 2026-01-16 | Initial draft | Claude |
