# Webhook HMAC signing (EA-7 / PR-38)

When a webhook notification channel has a `signingSecret` configured,
Uptime Sentinel sends two extra headers on every outbound request:

```
X-Sentinel-Timestamp: 1716530400
X-Sentinel-Signature: sha256=2a8f3b…
```

The signature is computed as:

```
sig = HMAC_SHA256(signingSecret, `${timestamp}.${rawBody}`)
```

`timestamp` is unix seconds (not milliseconds). `rawBody` is the
exact UTF-8 string sent as the request body (the JSON payload after
`JSON.stringify`).

## Verifying on the receiver

### Node.js (Express)

```js
import { createHmac, timingSafeEqual } from 'crypto';

const SECRET = process.env.SENTINEL_WEBHOOK_SECRET;
const MAX_AGE_SECONDS = 300;

app.post('/incoming/sentinel', express.raw({ type: 'application/json' }), (req, res) => {
    const ts = req.header('X-Sentinel-Timestamp');
    const sig = req.header('X-Sentinel-Signature');
    if (!ts || !sig) return res.status(401).send('missing signature headers');

    // Replay defence
    const ageSeconds = Math.abs(Date.now() / 1000 - Number(ts));
    if (ageSeconds > MAX_AGE_SECONDS) return res.status(401).send('stale');

    // Recompute signature
    const expected = createHmac('sha256', SECRET)
        .update(`${ts}.${req.body.toString('utf8')}`)
        .digest('hex');
    const provided = sig.replace(/^sha256=/, '');

    // Constant-time compare to defeat timing oracles
    const ok =
        provided.length === expected.length &&
        timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex'));
    if (!ok) return res.status(401).send('bad signature');

    const payload = JSON.parse(req.body.toString('utf8'));
    // ... do the thing
    res.sendStatus(200);
});
```

### Go (net/http)

```go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func handle(w http.ResponseWriter, r *http.Request) {
    secret := []byte(os.Getenv("SENTINEL_WEBHOOK_SECRET"))
    ts := r.Header.Get("X-Sentinel-Timestamp")
    sig := strings.TrimPrefix(r.Header.Get("X-Sentinel-Signature"), "sha256=")
    if ts == "" || sig == "" {
        http.Error(w, "missing signature", http.StatusUnauthorized)
        return
    }

    tsInt, err := strconv.ParseInt(ts, 10, 64)
    if err != nil || abs(time.Now().Unix()-tsInt) > 300 {
        http.Error(w, "stale or invalid timestamp", http.StatusUnauthorized)
        return
    }

    body, _ := io.ReadAll(r.Body)
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(ts + "." + string(body)))
    expected := hex.EncodeToString(mac.Sum(nil))
    provided, _ := hex.DecodeString(sig)
    expectedBytes, _ := hex.DecodeString(expected)
    if !hmac.Equal(provided, expectedBytes) {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }

    // ... do the thing
    w.WriteHeader(http.StatusOK)
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }
```

## Why these specific choices

| Choice | Why |
|---|---|
| HMAC-SHA256 | Industry-standard; well-supported in every language; resistant to length-extension attacks (which is why we don't use raw SHA-256) |
| Timestamp in seconds | Smaller header value, no need for ms precision in replay defence |
| Replay window 5 min | Tight enough to limit attacker's window; loose enough to tolerate clock skew + retry buffer |
| `${ts}.${body}` join | Standard Stripe-style format. Receivers in every language can reproduce trivially |
| Per-channel secret | Compromise of one webhook doesn't affect others; rotation is per-channel |

## Generating a secret

When you create a webhook channel in Uptime Sentinel, you provide the
`signingSecret`. Generate one with:

```bash
openssl rand -base64 32
```

(or any other 32-byte source of CSPRNG randomness; base64 encoding is
ergonomic but the secret is treated as a UTF-8 string for HMAC input).

The secret is stored encrypted at rest using `CHANNEL_ENCRYPTION_KEY`
(see the existing `secret-vault` envelope format).

## Constant-time comparison matters

Both code samples use `timingSafeEqual` / `hmac.Equal`. Plain `===`
or `bytes.Equal` leak signature length information through timing
side-channels. An attacker who can measure response latency can use
that to brute-force the signature byte-by-byte. The constant-time
compare prevents this.

## Rotating a secret

1. Update the channel config in Settings → Notifications (paste a new secret).
2. Update your receiver to accept BOTH the old and new secret for a transition window.
3. Wait for the next webhook to arrive — confirm it verifies with the new secret.
4. Remove the old secret from your receiver.

There is no "key version" mechanism — keep the transition window short.
