# Payroll Webhook HMAC Key Rotation Procedure

**Spec:** 050-everee-payroll-foundations  
**Scope:** PRD F8 — webhook signature management  
**Audience:** Ops / Engineering  

---

## Overview

Everee webhook payloads are signed with HMAC-SHA256. BuyerKiosk verifies the signature
on every inbound `POST /api/payroll/webhook/everee` request before touching any event data.

The signing secret is stored encrypted in `payrollTenants.webhookSecretEncrypted` (per-tenant
mode) or in the environment variable `EVEREE_WEBHOOK_GLOBAL_SECRET` (global mode).

During a key rotation there is a **window** where Everee may send events signed under either
the old key or the new key (Everee rotates asynchronously on their side). BuyerKiosk supports
this via a **prior-secret column** that allows the verifier to accept signatures under either
key during the documented window.

---

## Per-Tenant Schema Columns

The `payrollTenants` table carries these columns for rotation support:

| Column | Type | Purpose |
|--------|------|---------|
| `webhookSecretEncrypted` | TEXT | Current signing secret (AES-256-CBC, base64) |
| `webhookPriorSecretEncrypted` | TEXT NULL | Previous signing secret during rotation window |
| `webhookPriorSecretExpiresAt` | DATETIME NULL | When the prior secret stops being accepted |

`webhookPriorSecretEncrypted` and `webhookPriorSecretExpiresAt` are added by the spec-050
migrations. Until a rotation is in progress both are NULL and the verifier uses only the
current secret.

---

## Rotation Procedure

### Step 1 — Request a new signing secret from Everee

Contact Everee partner support or use the Everee portal to generate a new webhook signing
secret for the affected company. Everee will confirm the activation time.

### Step 2 — Store the NEW secret as the current secret, move the old to prior

Using the BuyerKiosk admin interface or the payroll setup CLI (Phase 1b):

```sql
-- Example rotation for tenant id=1
-- 1. Read the current encrypted secret.
-- 2. Move it to prior-secret slot.
-- 3. Encrypt + write the new secret to current slot.
-- 4. Set the expiry window (recommend 48h after Everee confirms full cutover).
UPDATE kiosk_buykiosk.payrollTenants
   SET webhookPriorSecretEncrypted   = webhookSecretEncrypted,
       webhookPriorSecretExpiresAt   = DATE_ADD(NOW(), INTERVAL 48 HOUR),
       webhookSecretEncrypted        = '<new_encrypted_secret>'
 WHERE id = 1;
```

**Do NOT** use raw SQL in production — use `EvereeTokenStorage::encryptWebhookSecretForStorage()`
to encrypt before storing. The encrypted value is `base64(AES-256-CBC($plaintext, $masterKey))`.

### Step 3 — Verify dual-accept is active

During the window, `EvereeWebhookHandler::resolveCandidateSecrets()` returns BOTH the current
AND prior secret. Any event signed under either key will be accepted.

To verify:
1. Trigger a test event from the Everee sandbox.
2. Confirm `payrollAuditLog` shows `action = payroll.webhook.received` with `hmacValid = 1`.

### Step 4 — Let the window expire

After `webhookPriorSecretExpiresAt` passes, the prior secret is automatically ignored.
The `resolveCandidateSecrets()` method checks:
```php
if ($tenant->getWebhookPriorSecretExpiresAt() !== null
    && $tenant->getWebhookPriorSecretExpiresAt() > new DateTime()) {
    // within window — add prior secret to candidates
}
```

### Step 5 — Clean up

Once confident the rotation is complete (no errors in audit log, window expired):

```sql
UPDATE kiosk_buykiosk.payrollTenants
   SET webhookPriorSecretEncrypted = NULL,
       webhookPriorSecretExpiresAt = NULL
 WHERE id = 1;
```

---

## Global Mode (EVEREE_WEBHOOK_GLOBAL_SECRET)

When `EVEREE_WEBHOOK_SIGNING_MODE=global`, the secret comes from the environment variable
`EVEREE_WEBHOOK_GLOBAL_SECRET`. Rotation in global mode:

1. Set `EVEREE_WEBHOOK_PRIOR_SECRET` to the old value.
2. Set `EVEREE_WEBHOOK_GLOBAL_SECRET` to the new value.
3. `EvereeWebhookHandler` checks both env vars during the rotation window.
4. After the window, unset `EVEREE_WEBHOOK_PRIOR_SECRET`.

Global mode is intended for single-tenant sandbox setups. Per-tenant mode is required in
production for multi-store isolation.

---

## Error Monitoring During Rotation

Watch the payroll audit log for:

```sql
SELECT * FROM kiosk_buykiosk.payrollAuditLog
 WHERE action = 'payroll.webhook.received'
   AND JSON_EXTRACT(metadata, '$.hmacValid') = false
 ORDER BY createdAt DESC
 LIMIT 20;
```

A spike of `hmacValid=false` events during or after rotation indicates the new secret
may not have been correctly stored. Do NOT extend the rotation window indefinitely — if
the new secret is wrong, re-fetch from Everee and re-rotate.

---

## Related Files

- `userfrosting/src/BuyerKiosk/Payroll/Services/EvereeTokenStorage.php` — encrypt/decrypt
- `userfrosting/src/BuyerKiosk/Payroll/Services/EvereeWebhookHandler.php` — verification
- `userfrosting/src/BuyerKiosk/Payroll/Repositories/PayrollTenantRepository.php` — persistence
