diff --git a/docs/api/endpoints/webhook-triggers.md b/docs/api/endpoints/webhook-triggers.md index 3c221ceb3..c66e32192 100644 --- a/docs/api/endpoints/webhook-triggers.md +++ b/docs/api/endpoints/webhook-triggers.md @@ -1,164 +1,199 @@ # Webhook Trigger Endpoint -**Implementation**: `inc/Api/WebhookTrigger.php`, `inc/Api/WebhookSignatureVerifier.php` +**Implementation**: `inc/Api/WebhookTrigger.php`, `inc/Api/WebhookVerifier.php`, +`inc/Api/WebhookAuthResolver.php` **Base URL**: `/wp-json/datamachine/v1/trigger/{flow_id}` -**Since**: 0.30.0 (Bearer auth), 0.79.0 (HMAC-SHA256 auth) +**Since**: 0.30.0 (Bearer auth), 0.79.0 (template-based HMAC verifier) ## Overview -Public REST endpoint for triggering flows from external services — webhooks from -GitHub, Stripe, Shopify, Slack, Linear, or any custom upstream. Complements the -admin-only `/execute` endpoint and the mid-pipeline `WebhookGate` step. +Public REST endpoint for triggering flows from inbound HTTP requests. Auth is +**per-flow** and independent of WordPress user capabilities. Two primitives: -Authentication is per-flow and independent of WordPress capabilities. Each flow -chooses between two auth modes: +| Mode | Purpose | Auth material | +|----------|------------------------------------------------------|------------------------| +| `bearer` | First-party callers you control. | Per-flow 64-char hex token. | +| `hmac` | Third-party senders that HMAC-sign request content. | Shared secret(s) + a signing template. | -| Mode | When to use | Header | -|---------------|--------------------------------------------------------|--------------------------------| -| `bearer` | Default. First-party callers you control. | `Authorization: Bearer ` | -| `hmac_sha256` | Third-party providers that sign the raw request body. | Provider-specific (configurable) | +**DM core ships zero provider names**, anywhere. All HMAC behaviour is driven +by a declarative **signing template** stored on the flow. Templates can be +hand-written or expanded from a **preset** registered via the +`datamachine_webhook_auth_presets` filter. -Existing flows with no `webhook_auth_mode` value default to `bearer` — there is -zero behavior change for anything shipped before 0.79.0. - -## Auth mode: `bearer` +## Bearer mode Generated with `wp datamachine flows webhook enable `. The flow gets a 32-byte hex token. Callers present it in the `Authorization` header: ```bash curl -X POST https://example.com/wp-json/datamachine/v1/trigger/42 \ - -H "Authorization: Bearer <64-char-hex-token>" \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"key": "value"}' ``` -Token comparison is timing-safe via `hash_equals`. Token rotation is -`wp datamachine flows webhook regenerate ` — old tokens are +Token comparison is timing-safe via `hash_equals`. Rotate with +`wp datamachine flows webhook regenerate ` — the old token is invalidated immediately. -## Auth mode: `hmac_sha256` - -Industry-standard webhook signature verification against the **raw request -body**. Supported by GitHub, Stripe, Shopify, Slack, Linear, Mailgun, PayPal, -SendGrid, Twilio, Plaid, and most major SaaS providers. - -### Flow - -1. Flow stores a shared `webhook_secret` (never exposed via status). -2. Upstream signs the raw request body with that secret using HMAC-SHA256. -3. Signature is sent in a provider-specific header. -4. DM recomputes the signature with `hash_hmac('sha256', $raw_body, $secret)` - and compares via `hash_equals`. +## HMAC mode — the template verifier -### Supported signature formats +The verifier is a single engine driven entirely by config. No provider names +exist in DM core; the config describes **how** a sender signs, not **which** +sender is signing. -| `signature_format` | Encoding | Example provider | -|--------------------|-----------------------------------|-----------------------| -| `sha256=hex` | `sha256=` + lowercase hex (default) | GitHub | -| `hex` | raw hex | Linear | -| `base64` | base64-encoded raw digest | Shopify | - -### Payload size cap - -By default, HMAC flows reject bodies larger than 1 MB (`413 Payload Too Large`) -before running HMAC, so unauthenticated clients cannot force the server to hash -arbitrarily large payloads. Override with: +### Template config ```php -scheduling_config['webhook_max_body_bytes'] = 2097152; // 2 MB +// Stored at scheduling_config['webhook_auth'] +[ + 'mode' => 'hmac', + 'algo' => 'sha256', // sha1 | sha256 | sha512 + + // What to hash. Placeholders: + // {body} — raw request body + // {timestamp} — value extracted from timestamp_source + // {id} — value extracted from id_source + // {url} — full request URL + // {header:} — value of a specific request header + // {param:} — query param first, then body param + 'signed_template' => '{timestamp}.{body}', + + // Where the signature lives. + 'signature_source' => [ + 'header' => 'X-Request-Signature', // OR 'param' => '' + 'extract' => [ + 'kind' => 'kv_pairs', // 'raw' | 'prefix' | 'kv_pairs' | 'regex' + 'key' => 'v1', + 'separator' => ',', + 'pair_separator' => '=', // default '=' + ], + 'encoding' => 'hex', // 'hex' | 'base64' | 'base64url' + ], + + // Optional: presence enables replay protection. + 'timestamp_source' => [ + 'header' => 'X-Request-Signature', + 'extract' => [ 'kind' => 'kv_pairs', 'key' => 't', 'separator' => ',' ], + 'format' => 'unix', // 'unix' | 'unix_ms' | 'iso8601' + ], + + // Optional: for templates that reference `{id}`. + 'id_source' => [ 'header' => 'X-Event-Id' ], + + 'tolerance_seconds' => 300, // replay window + 'max_body_bytes' => 1048576, // 413 on overflow; 0 = unlimited +] ``` -Set to `0` to disable the cap (not recommended). +### Extract kinds -### What gets passed to the flow +- **`raw`** — use the whole source value after trimming. +- **`prefix`** — require `extract.key` at the start; return what follows. +- **`kv_pairs`** — split on `extract.separator`, return the value associated + with `extract.key`. Use `pair_separator` to change `=` if needed. +- **`regex`** — PCRE pattern; capture group 1 (or full match if none). -On successful authentication, the flow runs with this structure in -`initial_data.webhook_trigger`: +### Signature encodings -```json -{ - "payload": { ... decoded JSON body ... }, - "received_at": "2026-04-24T12:34:56Z", - "remote_ip": "203.0.113.10", - "headers": { "content-type": "...", "x-github-event": "...", ... }, - "auth_mode": "hmac_sha256" -} -``` +- **`hex`** — lowercase or uppercase hex. +- **`base64`** — RFC 4648 base64. +- **`base64url`** — URL-safe base64 (`-_` instead of `+/`, padding optional). -## GitHub webhook walkthrough (end-to-end) +### Secrets and rotation -1. **Generate a secret and enable HMAC auth on the flow**: +Secrets live in `scheduling_config['webhook_secrets']` as an array. Each entry: - ```bash - wp datamachine flows webhook enable 42 \ - --auth-mode=hmac_sha256 \ - --generate-secret - ``` +```php +[ 'id' => 'current', 'value' => '...', 'expires_at' => null ] +``` - Output: +Any active (non-expired) secret whose HMAC matches the incoming signature wins. +**Zero-downtime rotation** is built in: - ``` - Success: Webhook trigger enabled for flow 42 (hmac_sha256). - URL: https://example.com/wp-json/datamachine/v1/trigger/42 - Auth mode: hmac_sha256 - Header: X-Hub-Signature-256 - Format: sha256=hex - Secret: <64-char-hex> - Warning: Save this secret now — it will not be shown again. - ``` +```bash +# Install a new secret; keep the old one verifying for 7 days (default). +wp datamachine flows webhook rotate 42 --generate +wp datamachine flows webhook rotate 42 --generate --previous-ttl-seconds=86400 -2. **Copy the secret** into GitHub: - - Repo → Settings → Webhooks → Add webhook - - **Payload URL**: the `URL` printed above - - **Content type**: `application/json` - - **Secret**: paste the secret from step 1 - - **Which events**: select the events you want (e.g. Pull requests) - - Save. +# Once the upstream has been updated, drop the old secret. +wp datamachine flows webhook forget 42 previous +``` -3. **Test**: GitHub sends a ping event. The flow executes with the payload in - `initial_data.webhook_trigger.payload`. +## Presets (filter-based, provider-agnostic) -### Rotating the secret later +DM core ships **zero presets**. Third parties register them via a filter, then +users select one by name. The preset name is a **lookup key** — it expands +server-side into a full template, which is what's persisted on the flow. +Changing a preset registration after a flow is enabled does not silently +mutate the flow's resolved template. -```bash -wp datamachine flows webhook set-secret 42 --generate +```php +add_filter( 'datamachine_webhook_auth_presets', function ( $presets ) { + $presets['my-upstream'] = [ + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{timestamp}.{body}', + 'signature_source' => [ + 'header' => 'X-Upstream-Signature', + 'extract' => [ 'kind' => 'kv_pairs', 'key' => 'v1', 'separator' => ',' ], + 'encoding' => 'hex', + ], + 'timestamp_source' => [ + 'header' => 'X-Upstream-Signature', + 'extract' => [ 'kind' => 'kv_pairs', 'key' => 't', 'separator' => ',' ], + 'format' => 'unix', + ], + 'tolerance_seconds' => 300, + ]; + return $presets; +} ); ``` -The old secret is invalidated immediately. Paste the new value into the -provider UI. +```bash +# Enable a flow via preset +wp datamachine flows webhook enable 42 --preset=my-upstream --generate-secret -## Other providers +# List registered presets (table / json / yaml) +wp datamachine flows webhook presets +``` -The pattern is identical — only the default header and format differ. +## Explicit templates (no preset required) -### Shopify (base64) +When you're wiring up a one-off sender, skip the filter and hand the template +directly to `enable`: ```bash wp datamachine flows webhook enable 42 \ - --auth-mode=hmac_sha256 \ - --signature-header=X-Shopify-Hmac-Sha256 \ - --signature-format=base64 \ - --secret= + --config=@template.json \ + --overrides=@overrides.json \ + --generate-secret ``` -### Linear (hex) +The `--overrides` file deep-merges on top of the config — useful for bumping +a tolerance window or swapping a header without rewriting the template. -```bash -wp datamachine flows webhook enable 42 \ - --auth-mode=hmac_sha256 \ - --signature-header=Linear-Signature \ - --signature-format=hex \ - --generate-secret -``` +## Payload passed into the flow + +On successful authentication, the flow runs with this structure in +`initial_data.webhook_trigger`: -### Slack / Stripe +```json +{ + "payload": { ... decoded JSON body ... }, + "received_at": "2026-04-24T12:34:56Z", + "remote_ip": "203.0.113.10", + "headers": { ... pattern-filtered headers ... }, + "auth_mode": "hmac" +} +``` -Slack and Stripe use timestamp-prefixed signatures (`v0=...`, `t=...,v1=...`) -that aren't directly representable by the three built-in formats. Support for -these is tracked as a follow-up — see issue #1177. +`headers` is built from a **pattern-based deny-list**: everything is included +except headers matching `/(secret|token|sig|hmac|signature|auth|password|bearer|api[-_]?key)/i` +plus the hard-coded `authorization` / `cookie` / `proxy-authorization`. No +provider-specific allow-list. ## Responses @@ -176,42 +211,66 @@ these is tracked as a follow-up — see issue #1177. ### 401 Unauthorized -Returned for **all** auth failures (missing/wrong Bearer token, missing/bad HMAC -signature, flow not found, webhook not enabled) to prevent information leakage: - -```json -{ "code": "unauthorized", "message": "Invalid or missing authorization.", "data": { "status": 401 } } -``` +Returned for all auth failures — missing token, bad signature, missing +signature header, stale timestamp, no active secret, no resolved template on +an HMAC flow. No distinguishable failure codes are surfaced to the caller; +the real failure reason is logged server-side for the flow owner. ### 413 Payload Too Large -Raw body exceeded `webhook_max_body_bytes` (HMAC mode only): - -```json -{ "code": "payload_too_large", "message": "Payload too large.", "data": { "status": 413 } } -``` +Raw body exceeded `webhook_auth.max_body_bytes` on an HMAC flow. ### 429 Too Many Requests Rate limit exceeded. See `wp datamachine flows webhook rate-limit`. +## Backward compatibility + +Flows configured with the v1 shorthand (`webhook_auth_mode = hmac_sha256` + +`webhook_signature_header` + `webhook_signature_format` + `webhook_secret`) +are migrated **silently, once, at first read**. After migration the legacy +fields are deleted from the flow row and the canonical v2 shape +(`webhook_auth_mode = hmac` + `webhook_auth` + `webhook_secrets`) replaces +them. + +Bearer flows are untouched. + +No code path outside the migration helper reads the legacy field names. + +## Non-HMAC primitives + +For Ed25519 (Discord), x509 (AWS SNS), JWT-signed webhooks, or mTLS, register +a mode class via `datamachine_webhook_verifier_modes`: + +```php +add_filter( 'datamachine_webhook_verifier_modes', function ( $modes ) { + $modes['ed25519'] = \My\Ed25519Verifier::class; + return $modes; +} ); +``` + +Each mode class implements a single static `verify()` method with the same +signature as `WebhookVerifier::verify()`. Core ships `hmac`; everything else +is pluggable. + ## Security considerations - **Raw body is sacred.** HMAC verification uses `$request->get_body()` — the exact bytes the sender signed. Any middleware that re-serializes JSON before this endpoint will break verification. - **Constant-time comparison** via `hash_equals` in both auth modes. -- **Generic 401 on failure** — the endpoint never distinguishes between - "no such flow", "wrong token", "bad signature", or "missing header". -- **Secret storage** — the secret lives in the flow's `scheduling_config` JSON, - same column as `webhook_token`. Treat flow configs as secret-bearing. -- **Replay protection** is out of scope for this endpoint. Providers like Slack - and Stripe include signed timestamps that could be validated with a replay - window — tracked as a follow-up. +- **Generic 401 on failure** — the endpoint does not distinguish auth failure + modes to the caller. +- **Secret storage** — secrets live in the flow's `scheduling_config` JSON. + Treat flow configs as secret-bearing until a dedicated credentials table + lands. +- **Replay protection** requires `timestamp_source` and + `tolerance_seconds > 0`. Nonce storage (reject duplicate event ids) is a + future follow-up. ## Related - CLI: [`wp datamachine flows webhook`](../../core-system/wp-cli.md#datamachine-flows-webhook) -- Abilities: `datamachine/webhook-trigger-enable`, `…-disable`, `…-regenerate`, - `…-set-secret`, `…-rate-limit`, `…-status` -- Outbound counterpart: [Agent Ping tool](../../ai-tools/tools-overview.md) +- Abilities: `datamachine/webhook-trigger-enable`, `…-disable`, + `…-regenerate`, `…-set-secret`, `…-rotate-secret`, `…-forget-secret`, + `…-rate-limit`, `…-status` diff --git a/docs/core-system/abilities-api.md b/docs/core-system/abilities-api.md index 91de01d82..6a8705f94 100644 --- a/docs/core-system/abilities-api.md +++ b/docs/core-system/abilities-api.md @@ -67,16 +67,18 @@ All abilities support `agent_id` and `user_id` parameters for multi-agent scopin | `datamachine/queue-move` | Reorder queue item | `Flow/QueueAbility.php` | | `datamachine/queue-settings` | Get/set queue settings | `Flow/QueueAbility.php` | -### Webhook Triggers (6 abilities) +### Webhook Triggers (8 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/webhook-trigger-enable` | Enable webhook trigger for a flow. Supports `bearer` (default) or `hmac_sha256` auth modes. | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-disable` | Disable webhook trigger, revoke all auth material (token and HMAC secret) | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-regenerate` | Regenerate Bearer token (bearer auth mode only; old token immediately invalidated) | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-set-secret` | Set or rotate the HMAC shared secret; switches the flow to `hmac_sha256` mode | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-rate-limit` | Set rate limiting for flow webhook trigger | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-status` | Get webhook trigger status for a flow (auth mode, header, format — never the secret) | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-enable` | Enable webhook trigger for a flow. Supports `bearer` (default) or `hmac` (template-based). | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-disable` | Disable webhook trigger, revoke all auth material (token, template, secrets). | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-regenerate` | Regenerate Bearer token (bearer mode only; old token immediately invalidated). | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-set-secret` | Set or replace a specific secret id on an existing HMAC flow (no grace window). | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-rotate-secret` | **Zero-downtime rotation** — demote current → previous with a TTL, install a fresh current. | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-forget-secret` | Remove a specific secret by id from the rotation list. | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-rate-limit` | Set rate limiting for flow webhook trigger. | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-status` | Get webhook trigger status — auth mode, template, secret ids. Never the secret values. | `Flow/WebhookTriggerAbility.php` | ### Job Execution (9 abilities) diff --git a/docs/core-system/wp-cli.md b/docs/core-system/wp-cli.md index 26c9d8c29..0953211e2 100644 --- a/docs/core-system/wp-cli.md +++ b/docs/core-system/wp-cli.md @@ -107,49 +107,56 @@ wp datamachine flows queue validate 10 "AI agents" --post_type=post --threshold= ### datamachine flows webhook -Manage webhook triggers. Supports two auth modes: Bearer (default) and HMAC-SHA256. **Since**: 0.31.0 (Bearer), 0.79.0 (HMAC). +Manage webhook triggers. Two auth primitives: **bearer** (default) and **hmac** +(template-based, provider-agnostic). **Since**: 0.31.0 (Bearer), 0.79.0 (HMAC +template verifier). ```bash -# Enable webhook trigger with default Bearer auth +# Enable with default Bearer auth wp datamachine flows webhook enable 10 -# Enable with HMAC-SHA256 auth (GitHub-style) and a generated secret -wp datamachine flows webhook enable 10 --auth-mode=hmac_sha256 --generate-secret +# Enable with HMAC via a registered preset (core ships zero presets; +# they come from plugins / mu-plugins registering the filter). +wp datamachine flows webhook enable 10 --preset= --generate-secret -# Enable with HMAC for a non-GitHub provider (Shopify example) -wp datamachine flows webhook enable 10 \ - --auth-mode=hmac_sha256 \ - --signature-header=X-Shopify-Hmac-Sha256 \ - --signature-format=base64 \ - --secret= +# Enable with HMAC via an explicit template config +wp datamachine flows webhook enable 10 --config=@template.json --secret= -# Set or rotate the HMAC secret (prints the new secret once) +# Deep-merge overrides on top of a preset or config +wp datamachine flows webhook enable 10 --preset= \ + --overrides=@overrides.json --generate-secret + +# List available presets +wp datamachine flows webhook presets + +# Zero-downtime secret rotation — keeps the old secret verifying for 7d. +wp datamachine flows webhook rotate 10 --generate +wp datamachine flows webhook rotate 10 --generate --previous-ttl-seconds=86400 +wp datamachine flows webhook forget 10 previous + +# Replace a single secret id (no grace window). HMAC mode only. wp datamachine flows webhook set-secret 10 --generate -wp datamachine flows webhook set-secret 10 --secret= -# Check webhook status (shows auth mode; never shows secret/token) +# Regenerate the Bearer token (bearer mode only) +wp datamachine flows webhook regenerate 10 + +# Check webhook status — shows auth mode, template, secret ids (never values). wp datamachine flows webhook status 10 # List all webhook-enabled flows wp datamachine flows webhook list -# Regenerate Bearer token (bearer mode only) -wp datamachine flows webhook regenerate 10 - # Configure rate limiting wp datamachine flows webhook rate-limit 10 --max=10 --window=60 -# Disable webhook (clears all auth material, both modes) +# Disable webhook (clears all auth material) wp datamachine flows webhook disable 10 ``` -**Signature formats for HMAC mode** (`--signature-format`): -- `sha256=hex` (default) — GitHub-style `sha256=` header values. -- `hex` — raw hex digest (e.g. Linear). -- `base64` — base64-encoded raw digest (e.g. Shopify). - -See [Webhook Triggers](../api/endpoints/webhook-triggers.md) for the full -GitHub walkthrough and security notes. +**DM core ships no provider names.** Preset registrations belong in companion +plugins. See [Webhook Triggers](../api/endpoints/webhook-triggers.md) for the +template config grammar, the `datamachine_webhook_auth_presets` filter, and +the backward-compat migration path for legacy v1 flows. ### datamachine flows bulk-config diff --git a/inc/Abilities/Flow/WebhookTriggerAbility.php b/inc/Abilities/Flow/WebhookTriggerAbility.php index a2e282850..afccfc038 100644 --- a/inc/Abilities/Flow/WebhookTriggerAbility.php +++ b/inc/Abilities/Flow/WebhookTriggerAbility.php @@ -42,53 +42,59 @@ private function registerAbilities(): void { 'datamachine/webhook-trigger-enable', array( 'label' => __( 'Enable Webhook Trigger', 'data-machine' ), - 'description' => __( 'Enable webhook trigger for a flow. Supports Bearer token (default) or HMAC-SHA256 authentication. External services can POST to the trigger URL to start flow executions.', 'data-machine' ), + 'description' => __( 'Enable webhook trigger for a flow. Supports Bearer token (default) or HMAC (via a registered preset or an explicit template config).', 'data-machine' ), 'category' => 'datamachine-flow', 'input_schema' => array( 'type' => 'object', 'required' => array( 'flow_id' ), 'properties' => array( - 'flow_id' => array( + 'flow_id' => array( 'type' => 'integer', - 'description' => __( 'Flow ID to enable webhook trigger for', 'data-machine' ), + 'description' => __( 'Flow ID to enable webhook trigger for.', 'data-machine' ), ), - 'auth_mode' => array( + 'auth_mode' => array( 'type' => 'string', - 'enum' => array( 'bearer', 'hmac_sha256' ), - 'description' => __( 'Authentication mode. Defaults to bearer for backward compatibility.', 'data-machine' ), + 'enum' => array( 'bearer', 'hmac' ), + 'description' => __( 'Authentication primitive. Defaults to bearer.', 'data-machine' ), ), - 'signature_header' => array( + 'preset' => array( 'type' => 'string', - 'description' => __( 'HMAC signature header name (e.g. X-Hub-Signature-256). Only used when auth_mode is hmac_sha256.', 'data-machine' ), + 'description' => __( 'Name of a preset registered via the datamachine_webhook_auth_presets filter. Expands to a full template at enable-time; implies HMAC mode.', 'data-machine' ), ), - 'signature_format' => array( - 'type' => 'string', - 'enum' => array( 'sha256=hex', 'hex', 'base64' ), - 'description' => __( 'HMAC signature encoding. Only used when auth_mode is hmac_sha256.', 'data-machine' ), + 'template' => array( + 'type' => 'object', + 'description' => __( 'Explicit template config (v2 webhook_auth shape). Implies HMAC mode.', 'data-machine' ), + ), + 'template_overrides' => array( + 'type' => 'object', + 'description' => __( 'Deep-merged overrides applied on top of the preset or template.', 'data-machine' ), ), - 'generate_secret' => array( + 'generate_secret' => array( 'type' => 'boolean', - 'description' => __( 'When auth_mode is hmac_sha256, auto-generate a random 32-byte hex secret.', 'data-machine' ), + 'description' => __( 'Generate a random 32-byte hex secret (HMAC mode only).', 'data-machine' ), + ), + 'secret' => array( + 'type' => 'string', + 'description' => __( 'Explicit secret value (HMAC mode only; takes precedence over generate_secret).', 'data-machine' ), ), - 'secret' => array( + 'secret_id' => array( 'type' => 'string', - 'description' => __( 'When auth_mode is hmac_sha256, use this secret value (takes precedence over generate_secret).', 'data-machine' ), + 'description' => __( 'Secret id for multi-secret rotation (default: current).', 'data-machine' ), ), ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'webhook_url' => array( 'type' => 'string' ), - 'auth_mode' => array( 'type' => 'string' ), - 'token' => array( 'type' => 'string' ), - 'secret' => array( 'type' => 'string' ), - 'signature_header' => array( 'type' => 'string' ), - 'signature_format' => array( 'type' => 'string' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'webhook_url' => array( 'type' => 'string' ), + 'auth_mode' => array( 'type' => 'string' ), + 'token' => array( 'type' => 'string' ), + 'secret' => array( 'type' => 'string' ), + 'secret_ids' => array( 'type' => 'array' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), ), ), 'execute_callback' => array( $this, 'executeEnable' ), @@ -267,17 +273,16 @@ private function registerAbilities(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_name' => array( 'type' => 'string' ), - 'webhook_enabled' => array( 'type' => 'boolean' ), - 'webhook_url' => array( 'type' => 'string' ), - 'created_at' => array( 'type' => 'string' ), - 'auth_mode' => array( 'type' => 'string' ), - 'signature_header' => array( 'type' => 'string' ), - 'signature_format' => array( 'type' => 'string' ), - 'max_body_bytes' => array( 'type' => 'integer' ), - 'error' => array( 'type' => 'string' ), + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'flow_name' => array( 'type' => 'string' ), + 'webhook_enabled' => array( 'type' => 'boolean' ), + 'webhook_url' => array( 'type' => 'string' ), + 'created_at' => array( 'type' => 'string' ), + 'auth_mode' => array( 'type' => 'string' ), + 'template' => array( 'type' => 'object' ), + 'secret_ids' => array( 'type' => 'array' ), + 'error' => array( 'type' => 'string' ), ), ), 'execute_callback' => array( $this, 'executeStatus' ), @@ -285,6 +290,70 @@ private function registerAbilities(): void { 'meta' => array( 'show_in_rest' => true ), ) ); + + wp_register_ability( + 'datamachine/webhook-trigger-rotate-secret', + array( + 'label' => __( 'Rotate Webhook HMAC Secret', 'data-machine' ), + 'description' => __( 'Zero-downtime rotation. Demotes current → previous with a TTL, installs a fresh current. Both verify until previous expires.', 'data-machine' ), + 'category' => 'datamachine-flow', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id' ), + 'properties' => array( + 'flow_id' => array( 'type' => 'integer' ), + 'secret' => array( 'type' => 'string' ), + 'generate' => array( 'type' => 'boolean' ), + 'previous_ttl_seconds' => array( 'type' => 'integer' ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'new_secret' => array( 'type' => 'string' ), + 'previous_expires_at' => array( 'type' => 'string' ), + 'secret_ids' => array( 'type' => 'array' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeRotateSecret' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/webhook-trigger-forget-secret', + array( + 'label' => __( 'Forget Webhook HMAC Secret', 'data-machine' ), + 'description' => __( 'Immediately remove a specific secret by id from the rotation list.', 'data-machine' ), + 'category' => 'datamachine-flow', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id', 'secret_id' ), + 'properties' => array( + 'flow_id' => array( 'type' => 'integer' ), + 'secret_id' => array( 'type' => 'string' ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'secret_ids' => array( 'type' => 'array' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeForgetSecret' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); }; if ( doing_action( 'wp_abilities_api_init' ) ) { @@ -297,19 +366,20 @@ private function registerAbilities(): void { /** * Enable webhook trigger for a flow. * - * Supports two auth modes: - * - `bearer` (default): generates a 32-byte hex token. - * - `hmac_sha256`: stores a shared secret plus signature header/format. + * Auth modes: + * - `bearer` (default): generate a 32-byte hex token. + * - `hmac`: require a preset name OR an explicit template. + * No provider-specific defaults; no silent fallbacks. * - * If already enabled in the same mode, returns the existing config. - * Switching modes requires disabling and re-enabling. + * Re-calling enable with the same mode and no new secret material returns + * the existing config unchanged. Passing `--preset`, `--template`, or a + * fresh secret always re-configures the flow. * - * @param array $input Input with flow_id and optional auth_mode / header / format / secret. - * @return array Result with token or secret and webhook URL. + * @param array $input + * @return array */ public function executeEnable( array $input ): array { $flow_id = (int) ( $input['flow_id'] ?? 0 ); - if ( $flow_id <= 0 ) { return array( 'success' => false, @@ -325,22 +395,41 @@ public function executeEnable( array $input ): array { ); } - $scheduling_config = $flow['scheduling_config'] ?? array(); + // Run the one-time legacy migration before doing anything else so we + // read/write the canonical shape only. + $migration = \DataMachine\Api\WebhookAuthResolver::migrate_legacy( $flow['scheduling_config'] ?? array() ); + $scheduling_config = $migration['config']; + $preset_name = isset( $input['preset'] ) ? trim( (string) $input['preset'] ) : ''; + $template_in = isset( $input['template'] ) && is_array( $input['template'] ) ? $input['template'] : null; + $overrides = isset( $input['template_overrides'] ) && is_array( $input['template_overrides'] ) ? $input['template_overrides'] : array(); + + // A preset or explicit template implies HMAC. $requested_mode = isset( $input['auth_mode'] ) ? (string) $input['auth_mode'] : ''; if ( '' === $requested_mode ) { - $requested_mode = $scheduling_config['webhook_auth_mode'] ?? 'bearer'; + if ( '' !== $preset_name || null !== $template_in ) { + $requested_mode = 'hmac'; + } else { + $requested_mode = $scheduling_config['webhook_auth_mode'] ?? 'bearer'; + } } - if ( ! in_array( $requested_mode, array( 'bearer', 'hmac_sha256' ), true ) ) { + if ( ! in_array( $requested_mode, array( 'bearer', 'hmac' ), true ) ) { return array( 'success' => false, - 'error' => sprintf( 'Unknown auth_mode "%s". Expected bearer or hmac_sha256.', $requested_mode ), + 'error' => sprintf( 'Unknown auth_mode "%s". Expected bearer or hmac.', $requested_mode ), ); } - // If already enabled in the same mode with valid auth material, return existing config. - $existing_mode = $scheduling_config['webhook_auth_mode'] ?? 'bearer'; - if ( ! empty( $scheduling_config['webhook_enabled'] ) && $existing_mode === $requested_mode ) { + $has_new_secret = isset( $input['secret'] ) || ! empty( $input['generate_secret'] ); + $has_new_template = ( '' !== $preset_name ) || ( null !== $template_in ); + $existing_mode = $scheduling_config['webhook_auth_mode'] ?? 'bearer'; + + // No-change short-circuit. + if ( ! empty( $scheduling_config['webhook_enabled'] ) + && $existing_mode === $requested_mode + && ! $has_new_secret + && ! $has_new_template + ) { if ( 'bearer' === $requested_mode && ! empty( $scheduling_config['webhook_token'] ) ) { return array( 'success' => true, @@ -351,15 +440,14 @@ public function executeEnable( array $input ): array { 'message' => 'Webhook trigger already enabled.', ); } - if ( 'hmac_sha256' === $requested_mode && ! empty( $scheduling_config['webhook_secret'] ) ) { + if ( 'hmac' === $requested_mode && ! empty( $scheduling_config['webhook_auth'] ) ) { return array( - 'success' => true, - 'flow_id' => $flow_id, - 'webhook_url' => self::get_webhook_url( $flow_id ), - 'auth_mode' => 'hmac_sha256', - 'signature_header' => $scheduling_config['webhook_signature_header'] ?? 'X-Hub-Signature-256', - 'signature_format' => $scheduling_config['webhook_signature_format'] ?? 'sha256=hex', - 'message' => 'Webhook trigger already enabled.', + 'success' => true, + 'flow_id' => $flow_id, + 'webhook_url' => self::get_webhook_url( $flow_id ), + 'auth_mode' => 'hmac', + 'secret_ids' => self::summarize_secrets( $scheduling_config['webhook_secrets'] ?? array() ), + 'message' => 'Webhook trigger already enabled.', ); } } @@ -379,64 +467,88 @@ public function executeEnable( array $input ): array { if ( empty( $scheduling_config['webhook_token'] ) ) { $scheduling_config['webhook_token'] = self::generate_token(); } - // Clear HMAC-specific fields when switching to bearer. - unset( $scheduling_config['webhook_secret'] ); - unset( $scheduling_config['webhook_signature_header'] ); - unset( $scheduling_config['webhook_signature_format'] ); + // Clear every HMAC field when switching to bearer. + unset( + $scheduling_config['webhook_auth'], + $scheduling_config['webhook_secrets'] + ); $response['token'] = $scheduling_config['webhook_token']; $response['message'] = sprintf( 'Webhook trigger enabled for flow %d (bearer).', $flow_id ); } else { - // HMAC mode — resolve secret from input (explicit > generate > existing). + // HMAC mode — must resolve a template, either from preset or explicit input. + if ( '' !== $preset_name ) { + $presets = \DataMachine\Api\WebhookAuthResolver::get_presets(); + if ( ! isset( $presets[ $preset_name ] ) ) { + return array( + 'success' => false, + 'error' => sprintf( + 'Unknown preset "%s". Register presets via the datamachine_webhook_auth_presets filter.', + $preset_name + ), + ); + } + $template = $presets[ $preset_name ]; + } elseif ( null !== $template_in ) { + $template = $template_in; + } elseif ( ! empty( $scheduling_config['webhook_auth'] ) ) { + $template = $scheduling_config['webhook_auth']; + } else { + return array( + 'success' => false, + 'error' => 'HMAC mode requires a preset (--preset=) or an explicit template (--template=...).', + ); + } + if ( ! empty( $overrides ) ) { + $template = \DataMachine\Api\WebhookAuthResolver::deep_merge( $template, $overrides ); + } + + // Normalise the template: force mode=hmac so filter-registered presets + // can't accidentally escape into other modes without an explicit decision. + $template['mode'] = 'hmac'; + + // Secret resolution: explicit > generate > existing in secrets roster. $explicit_secret = isset( $input['secret'] ) ? (string) $input['secret'] : ''; $generate = ! empty( $input['generate_secret'] ); + $secret_id = isset( $input['secret_id'] ) ? (string) $input['secret_id'] : 'current'; + if ( '' === $secret_id ) { + $secret_id = 'current'; + } + $existing_secrets = $scheduling_config['webhook_secrets'] ?? array(); + $new_secret = null; if ( '' !== $explicit_secret ) { - $scheduling_config['webhook_secret'] = $explicit_secret; - $response['secret'] = $explicit_secret; - } elseif ( $generate || empty( $scheduling_config['webhook_secret'] ) ) { - $new_secret = self::generate_secret(); - $scheduling_config['webhook_secret'] = $new_secret; - $response['secret'] = $new_secret; + $new_secret = $explicit_secret; + } elseif ( $generate || empty( $existing_secrets ) ) { + $new_secret = self::generate_secret(); } - if ( empty( $scheduling_config['webhook_secret'] ) ) { - return array( - 'success' => false, - 'error' => 'HMAC auth_mode requires a secret. Pass --generate-secret or --secret=.', + if ( null !== $new_secret ) { + $scheduling_config['webhook_secrets'] = self::upsert_secret( + $existing_secrets, + $secret_id, + $new_secret ); + $response['secret'] = $new_secret; + } else { + $scheduling_config['webhook_secrets'] = $existing_secrets; } - $header = isset( $input['signature_header'] ) ? trim( (string) $input['signature_header'] ) : ''; - if ( '' !== $header ) { - $scheduling_config['webhook_signature_header'] = $header; - } elseif ( empty( $scheduling_config['webhook_signature_header'] ) ) { - $scheduling_config['webhook_signature_header'] = 'X-Hub-Signature-256'; - } - - $format = isset( $input['signature_format'] ) ? (string) $input['signature_format'] : ''; - if ( '' !== $format ) { - if ( ! in_array( $format, \DataMachine\Api\WebhookSignatureVerifier::supported_formats(), true ) ) { - return array( - 'success' => false, - 'error' => sprintf( 'Unsupported signature_format "%s".', $format ), - ); - } - $scheduling_config['webhook_signature_format'] = $format; - } elseif ( empty( $scheduling_config['webhook_signature_format'] ) ) { - $scheduling_config['webhook_signature_format'] = 'sha256=hex'; + if ( empty( $scheduling_config['webhook_secrets'] ) ) { + return array( + 'success' => false, + 'error' => 'HMAC mode requires a secret. Pass --generate-secret or --secret=.', + ); } - // Clear Bearer-specific field when switching to HMAC. + $scheduling_config['webhook_auth'] = $template; unset( $scheduling_config['webhook_token'] ); - $response['signature_header'] = $scheduling_config['webhook_signature_header']; - $response['signature_format'] = $scheduling_config['webhook_signature_format']; - $response['message'] = sprintf( 'Webhook trigger enabled for flow %d (hmac_sha256).', $flow_id ); + $response['secret_ids'] = self::summarize_secrets( $scheduling_config['webhook_secrets'] ); + $response['message'] = sprintf( 'Webhook trigger enabled for flow %d (hmac).', $flow_id ); } $updated = $this->db_flows->update_flow( $flow_id, array( 'scheduling_config' => $scheduling_config ) ); - if ( ! $updated ) { return array( 'success' => false, @@ -458,21 +570,18 @@ public function executeEnable( array $input ): array { } /** - * Set or rotate the HMAC shared secret for a flow. - * - * Accepts either an explicit `secret` value or `generate=true` to produce - * a random 32-byte hex secret. The secret is returned once in the result - * and never exposed via `executeStatus`. + * Set or replace an HMAC secret for a flow. * - * Also flips `webhook_auth_mode` to `hmac_sha256` if not already set, so - * this command can be used as a one-liner for new HMAC flows. + * Requires the flow to already be in HMAC mode — it won't guess a template + * for you (no GitHub-style defaults). Use `enable --preset=` or + * `enable --template=...` first to establish a template, then rotate + * secrets with this command or with `rotate` for a grace window. * - * @param array $input Input with flow_id and either secret or generate=true. - * @return array Result with the new secret on success. + * @param array $input flow_id, secret|generate, optional secret_id. + * @return array */ public function executeSetSecret( array $input ): array { $flow_id = (int) ( $input['flow_id'] ?? 0 ); - if ( $flow_id <= 0 ) { return array( 'success' => false, @@ -480,10 +589,9 @@ public function executeSetSecret( array $input ): array { ); } - $explicit_secret = isset( $input['secret'] ) ? (string) $input['secret'] : ''; - $generate = ! empty( $input['generate'] ); - - if ( '' === $explicit_secret && ! $generate ) { + $explicit = isset( $input['secret'] ) ? (string) $input['secret'] : ''; + $generate = ! empty( $input['generate'] ); + if ( '' === $explicit && ! $generate ) { return array( 'success' => false, 'error' => 'Provide either secret= or generate=true.', @@ -498,26 +606,36 @@ public function executeSetSecret( array $input ): array { ); } - $scheduling_config = $flow['scheduling_config'] ?? array(); + $migration = \DataMachine\Api\WebhookAuthResolver::migrate_legacy( $flow['scheduling_config'] ?? array() ); + $scheduling_config = $migration['config']; - $secret = '' !== $explicit_secret ? $explicit_secret : self::generate_secret(); + if ( empty( $scheduling_config['webhook_auth'] ) ) { + return array( + 'success' => false, + 'error' => sprintf( + 'Flow %d has no HMAC template yet. Run `enable --preset=` or `enable --template=...` first.', + $flow_id + ), + ); + } - $scheduling_config['webhook_secret'] = $secret; - $scheduling_config['webhook_auth_mode'] = 'hmac_sha256'; + $secret = '' !== $explicit ? $explicit : self::generate_secret(); + $secret_id = isset( $input['secret_id'] ) ? (string) $input['secret_id'] : 'current'; + if ( '' === $secret_id ) { + $secret_id = 'current'; + } + + $scheduling_config['webhook_secrets'] = self::upsert_secret( + $scheduling_config['webhook_secrets'] ?? array(), + $secret_id, + $secret + ); + $scheduling_config['webhook_auth_mode'] = 'hmac'; $scheduling_config['webhook_enabled'] = true; $scheduling_config['webhook_created_at'] = $scheduling_config['webhook_created_at'] ?? gmdate( 'Y-m-d\TH:i:s\Z' ); - - if ( empty( $scheduling_config['webhook_signature_header'] ) ) { - $scheduling_config['webhook_signature_header'] = 'X-Hub-Signature-256'; - } - if ( empty( $scheduling_config['webhook_signature_format'] ) ) { - $scheduling_config['webhook_signature_format'] = 'sha256=hex'; - } - // Clear Bearer-specific field when switching into HMAC mode. unset( $scheduling_config['webhook_token'] ); $updated = $this->db_flows->update_flow( $flow_id, array( 'scheduling_config' => $scheduling_config ) ); - if ( ! $updated ) { return array( 'success' => false, @@ -528,16 +646,20 @@ public function executeSetSecret( array $input ): array { do_action( 'datamachine_log', 'info', - 'Webhook HMAC secret updated for flow', - array( 'flow_id' => $flow_id ) + 'Webhook HMAC secret updated', + array( + 'flow_id' => $flow_id, + 'secret_id' => $secret_id, + ) ); return array( - 'success' => true, - 'flow_id' => $flow_id, - 'secret' => $secret, - 'auth_mode' => 'hmac_sha256', - 'message' => sprintf( 'HMAC secret updated for flow %d. Old secret is invalidated.', $flow_id ), + 'success' => true, + 'flow_id' => $flow_id, + 'secret' => $secret, + 'secret_ids' => self::summarize_secrets( $scheduling_config['webhook_secrets'] ), + 'auth_mode' => 'hmac', + 'message' => sprintf( 'HMAC secret "%s" updated for flow %d.', $secret_id, $flow_id ), ); } @@ -569,14 +691,19 @@ public function executeDisable( array $input ): array { $scheduling_config = $flow['scheduling_config'] ?? array(); - unset( $scheduling_config['webhook_enabled'] ); - unset( $scheduling_config['webhook_token'] ); - unset( $scheduling_config['webhook_created_at'] ); - unset( $scheduling_config['webhook_auth_mode'] ); - unset( $scheduling_config['webhook_secret'] ); - unset( $scheduling_config['webhook_signature_header'] ); - unset( $scheduling_config['webhook_signature_format'] ); - unset( $scheduling_config['webhook_max_body_bytes'] ); + unset( + $scheduling_config['webhook_enabled'], + $scheduling_config['webhook_token'], + $scheduling_config['webhook_created_at'], + $scheduling_config['webhook_auth_mode'], + $scheduling_config['webhook_auth'], + $scheduling_config['webhook_secrets'], + // Legacy v1 fields — safe to clear even post-migration. + $scheduling_config['webhook_secret'], + $scheduling_config['webhook_signature_header'], + $scheduling_config['webhook_signature_format'], + $scheduling_config['webhook_max_body_bytes'] + ); $updated = $this->db_flows->update_flow( $flow_id, array( 'scheduling_config' => $scheduling_config ) ); @@ -640,7 +767,11 @@ public function executeRegenerate( array $input ): array { if ( 'bearer' !== $auth_mode ) { return array( 'success' => false, - 'error' => sprintf( 'regenerate only applies to bearer auth_mode (flow %d is %s). Use set-secret for HMAC flows.', $flow_id, $auth_mode ), + 'error' => sprintf( + 'regenerate only applies to bearer flows (flow %d is %s). Use rotate / set-secret for HMAC flows.', + $flow_id, + $auth_mode + ), ); } @@ -801,10 +932,15 @@ public function executeStatus( array $input ): array { ); } - $scheduling_config = $flow['scheduling_config'] ?? array(); - $enabled = ! empty( $scheduling_config['webhook_enabled'] ); + // Apply the one-time migration silently so status reports the canonical shape. + $migration = \DataMachine\Api\WebhookAuthResolver::migrate_legacy( $flow['scheduling_config'] ?? array() ); + $scheduling_config = $migration['config']; + if ( $migration['migrated'] ) { + $this->db_flows->update_flow( $flow_id, array( 'scheduling_config' => $scheduling_config ) ); + } - $result = array( + $enabled = ! empty( $scheduling_config['webhook_enabled'] ); + $result = array( 'success' => true, 'flow_id' => $flow_id, 'flow_name' => $flow['flow_name'] ?? '', @@ -817,12 +953,13 @@ public function executeStatus( array $input ): array { $result['created_at'] = $scheduling_config['webhook_created_at'] ?? ''; $result['auth_mode'] = $auth_mode; - if ( 'hmac_sha256' === $auth_mode ) { - $result['signature_header'] = $scheduling_config['webhook_signature_header'] ?? 'X-Hub-Signature-256'; - $result['signature_format'] = $scheduling_config['webhook_signature_format'] ?? 'sha256=hex'; - $result['max_body_bytes'] = (int) ( $scheduling_config['webhook_max_body_bytes'] - ?? \DataMachine\Api\WebhookTrigger::DEFAULT_MAX_BODY_BYTES ); - // NEVER include the secret. + if ( 'bearer' !== $auth_mode ) { + // Surface the template so a flow owner can see exactly what's configured, + // but never the secrets. The template isn't sensitive; secrets are. + if ( ! empty( $scheduling_config['webhook_auth'] ) ) { + $result['template'] = $scheduling_config['webhook_auth']; + } + $result['secret_ids'] = self::summarize_secrets( $scheduling_config['webhook_secrets'] ?? array() ); } $rate_config = $scheduling_config['webhook_rate_limit'] ?? array(); @@ -847,9 +984,6 @@ public static function generate_token(): string { /** * Generate a cryptographically secure HMAC shared secret. * - * Returned as a 64-character hex string so it can be safely pasted into - * provider webhook configuration UIs (GitHub, Shopify, etc.). - * * @return string 64-character hex secret. */ public static function generate_secret(): string { @@ -859,10 +993,264 @@ public static function generate_secret(): string { /** * Get the webhook trigger URL for a flow. * - * @param int $flow_id Flow ID. - * @return string Full webhook trigger URL. + * @param int $flow_id + * @return string */ public static function get_webhook_url( int $flow_id ): string { return rest_url( "datamachine/v1/trigger/{$flow_id}" ); } + + /** + * Zero-downtime secret rotation. + * + * Demotes `current` → `previous` (keeps verifying for --previous-ttl-seconds), + * installs a fresh `current`. Use before updating the upstream provider; + * then `forget previous` once the upstream swap is confirmed. + * + * @param array $input flow_id, optional secret|generate|previous_ttl_seconds. + * @return array + */ + public function executeRotateSecret( array $input ): array { + $flow_id = (int) ( $input['flow_id'] ?? 0 ); + if ( $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id must be a positive integer', + ); + } + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => sprintf( 'Flow %d not found', $flow_id ), + ); + } + + $migration = \DataMachine\Api\WebhookAuthResolver::migrate_legacy( $flow['scheduling_config'] ?? array() ); + $scheduling_config = $migration['config']; + + if ( empty( $scheduling_config['webhook_auth'] ) ) { + return array( + 'success' => false, + 'error' => sprintf( + 'Flow %d has no HMAC template yet. Run `enable --preset=` first.', + $flow_id + ), + ); + } + + $explicit = isset( $input['secret'] ) ? (string) $input['secret'] : ''; + $generate = ! empty( $input['generate'] ); + if ( '' === $explicit && ! $generate ) { + return array( + 'success' => false, + 'error' => 'Provide either secret= or generate=true.', + ); + } + + $ttl = isset( $input['previous_ttl_seconds'] ) ? (int) $input['previous_ttl_seconds'] : WEEK_IN_SECONDS; + if ( $ttl < 0 ) { + $ttl = 0; + } + $now = time(); + $expires_at = gmdate( 'Y-m-d\TH:i:s\Z', $now + $ttl ); + + $new_secret = '' !== $explicit ? $explicit : self::generate_secret(); + $secrets = $scheduling_config['webhook_secrets'] ?? array(); + if ( ! is_array( $secrets ) ) { + $secrets = array(); + } + + $demoted = array(); + foreach ( $secrets as $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + if ( 'current' === ( $entry['id'] ?? '' ) ) { + $entry['id'] = 'previous'; + $entry['expires_at'] = $expires_at; + } elseif ( 'previous' === ( $entry['id'] ?? '' ) && empty( $entry['expires_at'] ) ) { + $entry['expires_at'] = $expires_at; + } + $demoted[] = $entry; + } + $demoted[] = array( + 'id' => 'current', + 'value' => $new_secret, + ); + + $scheduling_config['webhook_secrets'] = $demoted; + $scheduling_config['webhook_auth_mode'] = 'hmac'; + $scheduling_config['webhook_enabled'] = true; + unset( $scheduling_config['webhook_token'] ); + + $updated = $this->db_flows->update_flow( $flow_id, array( 'scheduling_config' => $scheduling_config ) ); + if ( ! $updated ) { + return array( + 'success' => false, + 'error' => 'Failed to update flow scheduling config', + ); + } + + do_action( + 'datamachine_log', + 'info', + 'Webhook HMAC secret rotated', + array( + 'flow_id' => $flow_id, + 'expires_at' => $expires_at, + ) + ); + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'new_secret' => $new_secret, + 'previous_expires_at' => $expires_at, + 'secret_ids' => self::summarize_secrets( $demoted ), + 'message' => sprintf( + 'HMAC secret rotated for flow %d. Previous secret valid until %s.', + $flow_id, + $expires_at + ), + ); + } + + /** + * Immediately forget a specific secret by id. + * + * @param array $input flow_id, secret_id. + * @return array + */ + public function executeForgetSecret( array $input ): array { + $flow_id = (int) ( $input['flow_id'] ?? 0 ); + $secret_id = isset( $input['secret_id'] ) ? (string) $input['secret_id'] : ''; + + if ( $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id must be a positive integer', + ); + } + if ( '' === $secret_id ) { + return array( + 'success' => false, + 'error' => 'secret_id is required', + ); + } + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => sprintf( 'Flow %d not found', $flow_id ), + ); + } + + $migration = \DataMachine\Api\WebhookAuthResolver::migrate_legacy( $flow['scheduling_config'] ?? array() ); + $scheduling_config = $migration['config']; + + $secrets = $scheduling_config['webhook_secrets'] ?? array(); + if ( ! is_array( $secrets ) ) { + $secrets = array(); + } + + $filtered = array(); + $found = false; + foreach ( $secrets as $entry ) { + if ( is_array( $entry ) && ( $entry['id'] ?? '' ) === $secret_id ) { + $found = true; + continue; + } + $filtered[] = $entry; + } + + if ( ! $found ) { + return array( + 'success' => false, + 'error' => sprintf( 'No secret with id "%s" on flow %d.', $secret_id, $flow_id ), + ); + } + + $scheduling_config['webhook_secrets'] = $filtered; + + $updated = $this->db_flows->update_flow( $flow_id, array( 'scheduling_config' => $scheduling_config ) ); + if ( ! $updated ) { + return array( + 'success' => false, + 'error' => 'Failed to update flow scheduling config', + ); + } + + do_action( + 'datamachine_log', + 'info', + 'Webhook HMAC secret forgotten', + array( + 'flow_id' => $flow_id, + 'secret_id' => $secret_id, + ) + ); + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'secret_ids' => self::summarize_secrets( $filtered ), + 'message' => sprintf( 'Secret "%s" removed from flow %d.', $secret_id, $flow_id ), + ); + } + + /** + * Insert or replace a secret entry in the rotation list. + * + * @param array $existing + * @param string $id + * @param string $value + * @return array + */ + public static function upsert_secret( array $existing, string $id, string $value ): array { + $replaced = false; + $out = array(); + foreach ( $existing as $entry ) { + if ( is_array( $entry ) && ( $entry['id'] ?? '' ) === $id ) { + $out[] = array( + 'id' => $id, + 'value' => $value, + ); + $replaced = true; + continue; + } + $out[] = $entry; + } + if ( ! $replaced ) { + $out[] = array( + 'id' => $id, + 'value' => $value, + ); + } + return $out; + } + + /** + * Summarise a secrets list for a response: ids + expiry only, never values. + * + * @param mixed $secrets + * @return array + */ + public static function summarize_secrets( $secrets ): array { + $out = array(); + if ( ! is_array( $secrets ) ) { + return $out; + } + foreach ( $secrets as $entry ) { + if ( is_array( $entry ) && ! empty( $entry['value'] ) ) { + $out[] = array( + 'id' => (string) ( $entry['id'] ?? '' ), + 'expires_at' => isset( $entry['expires_at'] ) ? (string) $entry['expires_at'] : null, + ); + } + } + return $out; + } } diff --git a/inc/Api/WebhookAuthResolver.php b/inc/Api/WebhookAuthResolver.php new file mode 100644 index 000000000..6582e4a2f --- /dev/null +++ b/inc/Api/WebhookAuthResolver.php @@ -0,0 +1,224 @@ + + * scheduling_config[ 'webhook_secrets' ] = [ [...], ... ] + * + * Legacy v1 flows (`webhook_auth_mode = hmac_sha256` + `webhook_signature_*` + * + singular `webhook_secret`) are migrated **once** on first read via the + * `migrate_legacy()` helper, after which the legacy fields are deleted. + * + * No provider names live in this file. + * + * @package DataMachine\Api + * @since 0.79.0 + * @see https://github.com/Extra-Chill/data-machine/issues/1179 + */ + +namespace DataMachine\Api; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class WebhookAuthResolver { + + /** + * Resolve a scheduling_config into a canonical mode + verifier config. + * + * @param array $scheduling_config + * @return array{mode:string, verifier:?array, token:?string} + */ + public static function resolve( array $scheduling_config ): array { + $mode = $scheduling_config['webhook_auth_mode'] ?? 'bearer'; + + if ( 'bearer' === $mode ) { + return array( + 'mode' => 'bearer', + 'verifier' => null, + 'token' => (string) ( $scheduling_config['webhook_token'] ?? '' ), + ); + } + + // 'hmac' (or any non-bearer mode): require a fully-specified template. + $verifier = $scheduling_config['webhook_auth'] ?? null; + if ( ! is_array( $verifier ) ) { + return array( + 'mode' => $mode, + 'verifier' => null, + 'token' => null, + ); + } + + // Attach secrets from the flow if the template didn't ship its own. + if ( empty( $verifier['secrets'] ) && ! empty( $scheduling_config['webhook_secrets'] ) ) { + $verifier['secrets'] = $scheduling_config['webhook_secrets']; + } + + return array( + 'mode' => $verifier['mode'] ?? $mode, + 'verifier' => $verifier, + 'token' => null, + ); + } + + /** + * One-time migration of legacy v1 HMAC fields into the canonical v2 shape. + * + * Called by callers that own the flow row (ability + trigger handler). + * Returns a potentially-mutated scheduling_config. If any legacy fields + * were found, the caller should persist the result and the legacy fields + * are never seen again. + * + * Returns [ 'config' => , 'migrated' => ]. + * + * @param array $scheduling_config + * @return array{config:array,migrated:bool} + */ + public static function migrate_legacy( array $scheduling_config ): array { + $legacy_mode = $scheduling_config['webhook_auth_mode'] ?? null; + $has_legacy_fields = isset( $scheduling_config['webhook_signature_header'] ) + || isset( $scheduling_config['webhook_signature_format'] ) + || isset( $scheduling_config['webhook_secret'] ); + + // Only migrate when the flow is on the legacy v1 shorthand. + if ( 'hmac_sha256' !== $legacy_mode && ! $has_legacy_fields ) { + return array( + 'config' => $scheduling_config, + 'migrated' => false, + ); + } + if ( 'hmac_sha256' !== $legacy_mode ) { + // Orphaned legacy fields without the legacy mode — just drop them. + unset( + $scheduling_config['webhook_signature_header'], + $scheduling_config['webhook_signature_format'], + $scheduling_config['webhook_secret'] + ); + return array( + 'config' => $scheduling_config, + 'migrated' => true, + ); + } + + $scheduling_config['webhook_auth_mode'] = 'hmac'; + + // Only synthesise a template if one wasn't already there. + if ( empty( $scheduling_config['webhook_auth'] ) ) { + $scheduling_config['webhook_auth'] = self::v1_template( + (string) ( $scheduling_config['webhook_signature_header'] ?? 'X-Hub-Signature-256' ), + (string) ( $scheduling_config['webhook_signature_format'] ?? 'sha256=hex' ) + ); + } + + // Promote legacy single secret into the secrets roster. + if ( empty( $scheduling_config['webhook_secrets'] ) && ! empty( $scheduling_config['webhook_secret'] ) ) { + $scheduling_config['webhook_secrets'] = array( + array( + 'id' => 'current', + 'value' => (string) $scheduling_config['webhook_secret'], + ), + ); + } + + // Drop every legacy field — they will never be read again. + unset( + $scheduling_config['webhook_signature_header'], + $scheduling_config['webhook_signature_format'], + $scheduling_config['webhook_secret'] + ); + + return array( + 'config' => $scheduling_config, + 'migrated' => true, + ); + } + + /** + * Build a template config from the three legacy v1 fields. + * + * This is the ONLY place in DM core that knows about the + * `{sha256=hex | hex | base64}` v1 format enum. It exists solely to + * migrate pre-existing flows. No other code path reads these values. + * + * @internal + */ + private static function v1_template( string $header, string $format ): array { + $signature_source = array( + 'header' => $header, + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ); + + switch ( $format ) { + case 'sha256=hex': + $signature_source['extract'] = array( + 'kind' => 'prefix', + 'key' => 'sha256=', + ); + $signature_source['encoding'] = 'hex'; + break; + case 'base64': + $signature_source['encoding'] = 'base64'; + break; + case 'hex': + default: + $signature_source['encoding'] = 'hex'; + break; + } + + return array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => $signature_source, + 'max_body_bytes' => WebhookVerifier::DEFAULT_MAX_BODY_BYTES, + ); + } + + /** + * Presets are filter-registered v2 templates. Core ships zero presets. + * Third parties call: + * + * ```php + * add_filter( 'datamachine_webhook_auth_presets', function ( $p ) { + * $p[''] = [ full template config ]; + * return $p; + * } ); + * ``` + * + * Presets are expanded into a full `webhook_auth` block at enable-time + * and then the preset name is gone — the stored flow row contains only + * the resolved template. This guarantees preset registrations can change + * without silently altering already-configured flows. + * + * @return array + */ + public static function get_presets(): array { + $presets = apply_filters( 'datamachine_webhook_auth_presets', array() ); + return is_array( $presets ) ? $presets : array(); + } + + /** + * Recursive array merge: overrides replace scalars, sub-arrays merge deeply. + * + * @param array $base + * @param array $overrides + * @return array + */ + public static function deep_merge( array $base, array $overrides ): array { + foreach ( $overrides as $k => $v ) { + if ( is_array( $v ) && isset( $base[ $k ] ) && is_array( $base[ $k ] ) ) { + $base[ $k ] = self::deep_merge( $base[ $k ], $v ); + } else { + $base[ $k ] = $v; + } + } + return $base; + } +} diff --git a/inc/Api/WebhookSignatureVerifier.php b/inc/Api/WebhookSignatureVerifier.php index 8e8b93c5d..28ffe000b 100644 --- a/inc/Api/WebhookSignatureVerifier.php +++ b/inc/Api/WebhookSignatureVerifier.php @@ -1,14 +1,16 @@ get_param( 'flow_id' ); - // Load flow first so auth-mode branching can read scheduling_config. $db_flows = new Flows(); $flow = $db_flows->get_flow( $flow_id ); @@ -121,9 +116,16 @@ public static function handle_trigger( \WP_REST_Request $request ) { } $scheduling_config = $flow['scheduling_config'] ?? array(); - $webhook_enabled = ! empty( $scheduling_config['webhook_enabled'] ); - if ( ! $webhook_enabled ) { + // Silently upgrade legacy v1 HMAC fields into the canonical v2 shape. + // This happens once per flow, the first time any v1 flow is hit. + $migration = WebhookAuthResolver::migrate_legacy( $scheduling_config ); + if ( $migration['migrated'] ) { + $scheduling_config = $migration['config']; + $db_flows->update_flow( $flow_id, array( 'scheduling_config' => $scheduling_config ) ); + } + + if ( empty( $scheduling_config['webhook_enabled'] ) ) { do_action( 'datamachine_log', 'warning', @@ -141,12 +143,13 @@ public static function handle_trigger( \WP_REST_Request $request ) { ); } - $auth_mode = self::resolve_auth_mode( $scheduling_config ); + $resolved = WebhookAuthResolver::resolve( $scheduling_config ); + $auth_mode = $resolved['mode']; - if ( 'hmac_sha256' === $auth_mode ) { - $auth_error = self::authenticate_hmac( $flow_id, $scheduling_config, $request ); - } else { + if ( 'bearer' === $auth_mode ) { $auth_error = self::authenticate_bearer( $flow_id, $scheduling_config, $request ); + } else { + $auth_error = self::authenticate_via_verifier( $flow_id, $resolved, $request ); } if ( $auth_error instanceof \WP_Error ) { @@ -297,23 +300,6 @@ function () use ( $ability, $input ) { */ const DEFAULT_RATE_LIMIT_WINDOW = 60; - /** - * Resolve the effective webhook auth mode for a flow. - * - * Missing / unrecognized values default to `bearer` so existing flows - * behave identically to before HMAC support was added. - * - * @param array $scheduling_config Flow scheduling config. - * @return string Either 'bearer' or 'hmac_sha256'. - */ - private static function resolve_auth_mode( array $scheduling_config ): string { - $mode = $scheduling_config['webhook_auth_mode'] ?? 'bearer'; - if ( 'hmac_sha256' === $mode ) { - return 'hmac_sha256'; - } - return 'bearer'; - } - /** * Authenticate a webhook request using a per-flow Bearer token. * @@ -406,30 +392,31 @@ private static function authenticate_bearer( int $flow_id, array $scheduling_con } /** - * Authenticate a webhook request using HMAC-SHA256 signatures. + * Authenticate a webhook request via the template verifier. * - * Verifies the raw request body (`$request->get_body()`) against a - * provider-specified signature header, using the shared secret stored - * in `scheduling_config.webhook_secret`. + * Works for any mode other than `bearer`. Returns a generic 401 + * (or 413 for oversized payloads) so callers can't distinguish + * failure modes from the outside. The structured reason is logged + * server-side for the flow owner's diagnostics. * - * @param int $flow_id Flow ID. - * @param array $scheduling_config Flow scheduling config. - * @param \WP_REST_Request $request REST request object. + * @param int $flow_id + * @param array $resolved Output of WebhookAuthResolver::resolve(). + * @param \WP_REST_Request $request * @return \WP_Error|null WP_Error on failure, null on success. */ - private static function authenticate_hmac( int $flow_id, array $scheduling_config, \WP_REST_Request $request ): ?\WP_Error { - $secret = $scheduling_config['webhook_secret'] ?? ''; - if ( empty( $secret ) || ! is_string( $secret ) ) { + private static function authenticate_via_verifier( int $flow_id, array $resolved, \WP_REST_Request $request ): ?\WP_Error { + $verifier_config = $resolved['verifier'] ?? null; + if ( ! is_array( $verifier_config ) ) { do_action( 'datamachine_log', 'warning', - 'Webhook trigger: HMAC secret not configured for flow', + 'Webhook trigger: missing or malformed verifier config', array( 'flow_id' => $flow_id, 'remote_ip' => self::get_remote_ip( $request ), + 'mode' => $resolved['mode'] ?? 'unknown', ) ); - return new \WP_Error( 'unauthorized', 'Invalid or missing authorization.', @@ -437,47 +424,42 @@ private static function authenticate_hmac( int $flow_id, array $scheduling_confi ); } - $signature_header = $scheduling_config['webhook_signature_header'] ?? 'X-Hub-Signature-256'; - $signature_format = $scheduling_config['webhook_signature_format'] ?? WebhookSignatureVerifier::FORMAT_PREFIXED_HEX; + $raw_body = $request->get_body(); + $headers = self::collect_headers( $request ); + $query_params = (array) $request->get_query_params(); + $post_params = (array) $request->get_body_params(); + $url = self::build_request_url( $request ); - $provided = $request->get_header( $signature_header ); - if ( empty( $provided ) ) { - do_action( - 'datamachine_log', - 'warning', - 'Webhook trigger: Missing HMAC signature header', - array( - 'flow_id' => $flow_id, - 'remote_ip' => self::get_remote_ip( $request ), - 'signature_header' => $signature_header, - ) - ); - - return new \WP_Error( - 'unauthorized', - 'Invalid or missing authorization.', - array( 'status' => 401 ) - ); - } + $result = WebhookVerifier::verify( + $raw_body, + $headers, + $query_params, + $post_params, + $url, + $verifier_config + ); - $raw_body = $request->get_body(); + do_action( + 'datamachine_log', + $result->ok ? 'info' : 'warning', + 'Webhook trigger: verification ' . $result->reason, + array( + 'flow_id' => $flow_id, + 'remote_ip' => self::get_remote_ip( $request ), + 'mode' => $resolved['mode'] ?? 'hmac', + 'reason' => $result->reason, + 'secret_id' => $result->secret_id, + 'timestamp' => $result->timestamp, + 'skew_seconds' => $result->skew_seconds, + 'detail' => $result->detail, + ) + ); - // Enforce an optional max body size before running HMAC — unauthenticated - // clients can otherwise force the server to hash arbitrarily large payloads. - $max_body_bytes = (int) ( $scheduling_config['webhook_max_body_bytes'] ?? self::DEFAULT_MAX_BODY_BYTES ); - if ( $max_body_bytes > 0 && strlen( $raw_body ) > $max_body_bytes ) { - do_action( - 'datamachine_log', - 'warning', - 'Webhook trigger: Payload exceeds max_body_bytes', - array( - 'flow_id' => $flow_id, - 'remote_ip' => self::get_remote_ip( $request ), - 'size' => strlen( $raw_body ), - 'limit' => $max_body_bytes, - ) - ); + if ( $result->ok ) { + return null; + } + if ( WebhookVerificationResult::PAYLOAD_TOO_LARGE === $result->reason ) { return new \WP_Error( 'payload_too_large', 'Payload too large.', @@ -485,34 +467,43 @@ private static function authenticate_hmac( int $flow_id, array $scheduling_confi ); } - $valid = WebhookSignatureVerifier::verify_hmac_sha256( - $raw_body, - $provided, - $secret, - $signature_format + return new \WP_Error( + 'unauthorized', + 'Invalid or missing authorization.', + array( 'status' => 401 ) ); + } - if ( ! $valid ) { - do_action( - 'datamachine_log', - 'warning', - 'Webhook trigger: HMAC signature mismatch', - array( - 'flow_id' => $flow_id, - 'remote_ip' => self::get_remote_ip( $request ), - 'signature_header' => $signature_header, - 'signature_format' => $signature_format, - ) - ); - - return new \WP_Error( - 'unauthorized', - 'Invalid or missing authorization.', - array( 'status' => 401 ) - ); + /** + * Collect all request headers into a lower-case-keyed assoc array. + * + * @param \WP_REST_Request $request + * @return array + */ + private static function collect_headers( \WP_REST_Request $request ): array { + $out = array(); + foreach ( (array) $request->get_headers() as $name => $values ) { + $value = is_array( $values ) ? implode( ',', array_map( 'strval', $values ) ) : (string) $values; + $normalised = strtolower( str_replace( '_', '-', (string) $name ) ); + $out[ $normalised ] = $value; } + return $out; + } - return null; + /** + * Reconstruct the full request URL. + * + * @param \WP_REST_Request $request + * @return string + */ + private static function build_request_url( \WP_REST_Request $request ): string { + $route = ltrim( $request->get_route(), '/' ); + $url = rest_url( $route ); + $query = $request->get_query_params(); + if ( ! empty( $query ) ) { + $url = add_query_arg( $query, $url ); + } + return $url; } /** @@ -624,36 +615,31 @@ private static function get_remote_ip( \WP_REST_Request $request ): string { } /** - * Get safe subset of request headers for logging. + * Safe subset of request headers for logging. * - * Excludes the Authorization header to avoid logging tokens. + * Pattern-based deny-list — we log everything EXCEPT headers whose name + * matches a sensitive pattern (auth / cookies / anything that looks like + * a secret or signature). No provider-specific allow-list; works for + * every current and future webhook source by construction. * - * @param \WP_REST_Request $request REST request object. - * @return array Filtered headers. + * @param \WP_REST_Request $request + * @return array Filtered headers, lower-case-keyed. */ private static function get_safe_headers( \WP_REST_Request $request ): array { - $safe_keys = array( - 'content-type', - 'user-agent', - 'x-github-event', - 'x-github-delivery', - 'x-hub-signature-256', - 'x-webhook-id', - 'x-request-id', - 'x-shopify-topic', - 'x-shopify-hmac-sha256', - 'stripe-signature', - 'linear-signature', - 'x-slack-signature', - 'x-slack-request-timestamp', - ); + $deny_exact = array( 'authorization', 'cookie', 'proxy-authorization' ); + $deny_pattern = '/(?:secret|token|sig|hmac|signature|auth|password|bearer|api[-_]?key)/i'; $headers = array(); - foreach ( $safe_keys as $key ) { - $value = $request->get_header( $key ); - if ( $value ) { - $headers[ $key ] = $value; + foreach ( (array) $request->get_headers() as $name => $values ) { + $key = strtolower( str_replace( '_', '-', (string) $name ) ); + if ( in_array( $key, $deny_exact, true ) ) { + continue; + } + if ( preg_match( $deny_pattern, $key ) ) { + continue; } + $value = is_array( $values ) ? implode( ',', array_map( 'strval', $values ) ) : (string) $values; + $headers[ $key ] = $value; } return $headers; diff --git a/inc/Api/WebhookVerificationResult.php b/inc/Api/WebhookVerificationResult.php new file mode 100644 index 000000000..10589027a --- /dev/null +++ b/inc/Api/WebhookVerificationResult.php @@ -0,0 +1,61 @@ +ok = $ok; + $this->reason = $reason; + $this->secret_id = $secret_id; + $this->timestamp = $timestamp; + $this->skew_seconds = $skew_seconds; + $this->detail = $detail; + } + + public static function ok( ?string $secret_id = null, ?int $timestamp = null, ?int $skew = null ): self { + return new self( true, self::OK, $secret_id, $timestamp, $skew ); + } + + public static function fail( string $reason, ?string $detail = null, ?int $timestamp = null, ?int $skew = null ): self { + return new self( false, $reason, null, $timestamp, $skew, $detail ); + } +} diff --git a/inc/Api/WebhookVerifier.php b/inc/Api/WebhookVerifier.php new file mode 100644 index 000000000..9b260b376 --- /dev/null +++ b/inc/Api/WebhookVerifier.php @@ -0,0 +1,504 @@ + 'hmac', + * 'algo' => 'sha256', + * 'signed_template' => '{timestamp}.{body}', + * 'signature_source' => [ header|param, extract, encoding ], + * 'timestamp_source' => [ header|param, extract, format ], // optional + * 'id_source' => [ header|param, extract ], // optional + * 'tolerance_seconds'=> 300, + * 'secrets' => [ [ 'id' => '...', 'value' => '...', 'expires_at' => '...' ], ... ], + * 'max_body_bytes' => 1048576, + * ] + * ``` + * + * @param string $raw_body Raw request body bytes (as signed). + * @param array $headers Lower-case-keyed headers. + * @param array $query_params Query string parameters. + * @param array $post_params Form-encoded body parameters. + * @param string $url Full request URL. + * @param array $config Template config. + * @param int|null $now Override "now" for deterministic tests. + * @return WebhookVerificationResult + */ + public static function verify( + string $raw_body, + array $headers, + array $query_params, + array $post_params, + string $url, + array $config, + ?int $now = null + ): WebhookVerificationResult { + + $headers = self::lower_case_keys( $headers ); + $now = $now ?? time(); + $mode = $config['mode'] ?? 'hmac'; + + // Non-HMAC primitives dispatch to a pluggable mode class. No provider + // names live here — the filter decides what modes exist. + if ( 'hmac' !== $mode ) { + $modes = apply_filters( 'datamachine_webhook_verifier_modes', array() ); + if ( isset( $modes[ $mode ] ) && is_callable( array( $modes[ $mode ], 'verify' ) ) ) { + return call_user_func( + array( $modes[ $mode ], 'verify' ), + $raw_body, + $headers, + $query_params, + $post_params, + $url, + $config, + $now + ); + } + return WebhookVerificationResult::fail( WebhookVerificationResult::UNKNOWN_MODE, "mode={$mode}" ); + } + + // Body-size cap — cheap pre-crypto check. + $max = (int) ( $config['max_body_bytes'] ?? self::DEFAULT_MAX_BODY_BYTES ); + if ( $max > 0 && strlen( $raw_body ) > $max ) { + return WebhookVerificationResult::fail( + WebhookVerificationResult::PAYLOAD_TOO_LARGE, + sprintf( 'size=%d limit=%d', strlen( $raw_body ), $max ) + ); + } + + $secrets = self::active_secrets( $config['secrets'] ?? array(), $now ); + if ( empty( $secrets ) ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::NO_ACTIVE_SECRET ); + } + + $signature_source = $config['signature_source'] ?? null; + if ( ! is_array( $signature_source ) ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::MALFORMED_CONFIG, 'signature_source missing' ); + } + + $sig_read = self::read_source( $signature_source, $headers, $query_params, $post_params ); + if ( null === $sig_read['raw'] ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::MISSING_HEADER, $sig_read['detail'] ); + } + if ( '' === $sig_read['extracted'] ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::MISSING_SIGNATURE, $sig_read['detail'] ); + } + + $encoding = $signature_source['encoding'] ?? 'hex'; + $sig_bytes = self::decode_signature( $sig_read['extracted'], $encoding ); + if ( null === $sig_bytes ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::BAD_SIGNATURE, 'undecodable signature' ); + } + + // Optional timestamp extraction + replay enforcement. + $timestamp = null; + $skew_seconds = null; + if ( ! empty( $config['timestamp_source'] ) && is_array( $config['timestamp_source'] ) ) { + $ts_read = self::read_source( $config['timestamp_source'], $headers, $query_params, $post_params ); + if ( null === $ts_read['raw'] || '' === $ts_read['extracted'] ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::MISSING_TIMESTAMP, $ts_read['detail'] ); + } + $timestamp = self::parse_timestamp( $ts_read['extracted'], $config['timestamp_source']['format'] ?? 'unix' ); + if ( null === $timestamp ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::MISSING_TIMESTAMP, 'unparseable timestamp' ); + } + $tolerance = (int) ( $config['tolerance_seconds'] ?? self::DEFAULT_TOLERANCE_SECONDS ); + $skew_seconds = abs( $now - $timestamp ); + if ( $tolerance > 0 && $skew_seconds > $tolerance ) { + return WebhookVerificationResult::fail( + WebhookVerificationResult::STALE_TIMESTAMP, + sprintf( 'skew=%ds tolerance=%ds', $skew_seconds, $tolerance ), + $timestamp, + $skew_seconds + ); + } + } + + // Optional id extraction for `{id}` placeholder. + $id_value = ''; + if ( ! empty( $config['id_source'] ) && is_array( $config['id_source'] ) ) { + $id_read = self::read_source( $config['id_source'], $headers, $query_params, $post_params ); + $id_value = $id_read['extracted']; + } + + $template = $config['signed_template'] ?? '{body}'; + if ( ! is_string( $template ) || '' === $template ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::MALFORMED_TEMPLATE ); + } + + $rendered = self::render_template( + $template, + array( + 'body' => $raw_body, + 'timestamp' => null !== $timestamp ? (string) $timestamp : '', + 'id' => $id_value, + 'url' => $url, + ), + $headers, + $query_params, + $post_params + ); + if ( null === $rendered ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::MALFORMED_TEMPLATE ); + } + + $algo = $config['algo'] ?? 'sha256'; + if ( ! in_array( $algo, hash_hmac_algos(), true ) ) { + return WebhookVerificationResult::fail( WebhookVerificationResult::MALFORMED_CONFIG, "algo={$algo}" ); + } + + // Try every active secret — first match wins. Timing-safe via hash_equals. + foreach ( $secrets as $secret ) { + if ( '' === $secret['value'] ) { + continue; + } + $expected = hash_hmac( $algo, $rendered, $secret['value'], true ); + if ( hash_equals( $expected, $sig_bytes ) ) { + return WebhookVerificationResult::ok( $secret['id'], $timestamp, $skew_seconds ); + } + } + + return WebhookVerificationResult::fail( + WebhookVerificationResult::BAD_SIGNATURE, + null, + $timestamp, + $skew_seconds + ); + } + + /* + ================================================================= + * Template rendering + * ================================================================= */ + + /** + * Render a signed-message template. Placeholders: {body}, {timestamp}, + * {id}, {url}, {header:}, {param:}. Unknown placeholders + * return null (caller surfaces MALFORMED_TEMPLATE). + * + * @return string|null + */ + private static function render_template( + string $template, + array $simple_tokens, + array $headers, + array $query_params, + array $post_params + ): ?string { + $malformed = false; + + $rendered = preg_replace_callback( + '/\{([a-z_]+)(?::([^}]+))?\}/i', + function ( array $m ) use ( $simple_tokens, $headers, $query_params, $post_params, &$malformed ) { + $kind = strtolower( $m[1] ); + $arg = $m[2] ?? ''; + + if ( '' === $arg ) { + if ( array_key_exists( $kind, $simple_tokens ) ) { + return $simple_tokens[ $kind ]; + } + $malformed = true; + return ''; + } + + switch ( $kind ) { + case 'header': + return (string) ( $headers[ strtolower( $arg ) ] ?? '' ); + case 'param': + if ( array_key_exists( $arg, $query_params ) ) { + $value = $query_params[ $arg ]; + } elseif ( array_key_exists( $arg, $post_params ) ) { + $value = $post_params[ $arg ]; + } else { + return ''; + } + return is_scalar( $value ) ? (string) $value : ''; + default: + $malformed = true; + return ''; + } + }, + $template + ); + + if ( $malformed || null === $rendered ) { + return null; + } + return $rendered; + } + + /* + ================================================================= + * Source extraction + * ================================================================= */ + + /** + * Read a source descriptor (signature / timestamp / id). + * + * extract.kind: + * raw (default) — whole value + * prefix — strip extract.key; empty if prefix absent + * kv_pairs — split on extract.separator, return value for extract.key + * regex — PCRE pattern; capture group 1 (or full match) + * + * @return array{raw:?string, extracted:string, detail:?string} + */ + private static function read_source( array $source, array $headers, array $query_params, array $post_params ): array { + $raw = null; + + if ( ! empty( $source['header'] ) ) { + $name = strtolower( (string) $source['header'] ); + $raw = $headers[ $name ] ?? null; + if ( null === $raw ) { + return array( + 'raw' => null, + 'extracted' => '', + 'detail' => "header={$source['header']}", + ); + } + } elseif ( ! empty( $source['param'] ) ) { + $name = (string) $source['param']; + if ( array_key_exists( $name, $query_params ) ) { + $raw = is_scalar( $query_params[ $name ] ) ? (string) $query_params[ $name ] : null; + } elseif ( array_key_exists( $name, $post_params ) ) { + $raw = is_scalar( $post_params[ $name ] ) ? (string) $post_params[ $name ] : null; + } + if ( null === $raw ) { + return array( + 'raw' => null, + 'extracted' => '', + 'detail' => "param={$name}", + ); + } + } else { + return array( + 'raw' => null, + 'extracted' => '', + 'detail' => 'source missing header or param', + ); + } + + return array( + 'raw' => $raw, + 'extracted' => self::apply_extract( $raw, $source['extract'] ?? array() ), + 'detail' => null, + ); + } + + private static function apply_extract( string $raw, array $rule ): string { + $kind = $rule['kind'] ?? 'raw'; + + switch ( $kind ) { + case 'raw': + return trim( $raw ); + + case 'prefix': + $prefix = (string) ( $rule['key'] ?? '' ); + if ( '' === $prefix ) { + return trim( $raw ); + } + if ( 0 !== strpos( $raw, $prefix ) ) { + return ''; + } + return trim( substr( $raw, strlen( $prefix ) ) ); + + case 'kv_pairs': + $separator = (string) ( $rule['separator'] ?? ',' ); + $key = (string) ( $rule['key'] ?? '' ); + if ( '' === $key || '' === $separator ) { + return ''; + } + $pair_sep = (string) ( $rule['pair_separator'] ?? '=' ); + foreach ( explode( $separator, $raw ) as $piece ) { + $piece = trim( $piece ); + if ( '' === $piece ) { + continue; + } + $eq = strpos( $piece, $pair_sep ); + if ( false === $eq ) { + continue; + } + $k = substr( $piece, 0, $eq ); + if ( $k === $key ) { + return trim( substr( $piece, $eq + strlen( $pair_sep ) ) ); + } + } + return ''; + + case 'regex': + $pattern = (string) ( $rule['pattern'] ?? '' ); + if ( '' === $pattern ) { + return ''; + } + // User patterns can be invalid — swallow warnings, treat as no match. + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler + set_error_handler( + static function () { + return true; + } + ); + $m = array(); + $result = preg_match( $pattern, $raw, $m ); + restore_error_handler(); + if ( 1 === $result ) { + return isset( $m[1] ) ? trim( $m[1] ) : trim( $m[0] ); + } + return ''; + + default: + return ''; + } + } + + /* + ================================================================= + * Decoding + timestamp parsing + secret lifecycle + * ================================================================= */ + + private static function decode_signature( string $sig, string $encoding ): ?string { + $sig = trim( $sig ); + switch ( $encoding ) { + case 'hex': + if ( ! ctype_xdigit( $sig ) || 0 !== strlen( $sig ) % 2 ) { + return null; + } + $bytes = hex2bin( $sig ); + return false === $bytes ? null : $bytes; + + case 'base64': + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $bytes = base64_decode( $sig, true ); + return false === $bytes ? null : $bytes; + + case 'base64url': + $padded = strtr( $sig, '-_', '+/' ); + $pad = strlen( $padded ) % 4; + if ( $pad ) { + $padded .= str_repeat( '=', 4 - $pad ); + } + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $bytes = base64_decode( $padded, true ); + return false === $bytes ? null : $bytes; + + default: + return null; + } + } + + private static function parse_timestamp( string $value, string $format ): ?int { + $value = trim( $value ); + if ( '' === $value ) { + return null; + } + switch ( $format ) { + case 'unix': + if ( ! preg_match( '/^-?\d+$/', $value ) ) { + return null; + } + return (int) $value; + case 'unix_ms': + if ( ! preg_match( '/^-?\d+$/', $value ) ) { + return null; + } + return (int) floor( ( (int) $value ) / 1000 ); + case 'iso8601': + $ts = strtotime( $value ); + return false === $ts ? null : (int) $ts; + default: + return null; + } + } + + /** + * Filter + normalise the active secrets list. Accepts: + * - `[ [ 'id' => '...', 'value' => '...', 'expires_at' => '...' ], ... ]` (canonical) + * - `[ 'raw-secret' ]` (flat list; auto-id'd) + * - `'raw-secret'` (single value) + * Entries with `expires_at` in the past are dropped. + * + * @return array + */ + private static function active_secrets( $secrets, int $now ): array { + if ( is_string( $secrets ) ) { + $secrets = array( $secrets ); + } + if ( ! is_array( $secrets ) ) { + return array(); + } + + $active = array(); + $index = 0; + foreach ( $secrets as $key => $entry ) { + if ( is_string( $entry ) ) { + $active[] = array( + 'id' => is_string( $key ) ? $key : ( 'secret_' . $index ), + 'value' => $entry, + ); + ++$index; + continue; + } + if ( ! is_array( $entry ) ) { + continue; + } + $value = (string) ( $entry['value'] ?? '' ); + if ( '' === $value ) { + continue; + } + $expires_at = $entry['expires_at'] ?? null; + if ( ! empty( $expires_at ) ) { + $ts = is_numeric( $expires_at ) ? (int) $expires_at : strtotime( (string) $expires_at ); + if ( false !== $ts && $ts <= $now ) { + continue; + } + } + $id = (string) ( $entry['id'] ?? ( 'secret_' . $index ) ); + if ( '' === $id ) { + $id = 'secret_' . $index; + } + $active[] = array( + 'id' => $id, + 'value' => $value, + ); + ++$index; + } + return $active; + } + + private static function lower_case_keys( array $in ): array { + $out = array(); + foreach ( $in as $k => $v ) { + $out[ strtolower( (string) $k ) ] = $v; + } + return $out; + } +} diff --git a/inc/Cli/Commands/Flows/WebhookCommand.php b/inc/Cli/Commands/Flows/WebhookCommand.php index cc71b005d..3c6f309c9 100644 --- a/inc/Cli/Commands/Flows/WebhookCommand.php +++ b/inc/Cli/Commands/Flows/WebhookCommand.php @@ -29,7 +29,7 @@ class WebhookCommand extends BaseCommand { */ public function dispatch( array $args, array $assoc_args ): void { if ( empty( $args ) ) { - WP_CLI::error( 'Usage: wp datamachine flows webhook [flow_id]' ); + WP_CLI::error( 'Usage: wp datamachine flows webhook [flow_id]' ); return; } @@ -50,6 +50,15 @@ public function dispatch( array $args, array $assoc_args ): void { case 'set_secret': $this->set_secret( $remaining, $assoc_args ); break; + case 'rotate': + $this->rotate( $remaining, $assoc_args ); + break; + case 'forget': + $this->forget( $remaining, $assoc_args ); + break; + case 'presets': + $this->presets( $remaining, $assoc_args ); + break; case 'status': $this->status( $remaining, $assoc_args ); break; @@ -60,16 +69,23 @@ public function dispatch( array $args, array $assoc_args ): void { $this->rate_limit( $remaining, $assoc_args ); break; default: - WP_CLI::error( "Unknown webhook action: {$action}. Use: enable, disable, regenerate, set-secret, status, list, rate-limit" ); + WP_CLI::error( "Unknown webhook action: {$action}. Use: enable, disable, regenerate, set-secret, rotate, forget, presets, status, list, rate-limit" ); } } /** * Enable webhook trigger for a flow. * - * Supports two authentication modes: + * Two auth modes, both generic primitives: + * * - `bearer` (default): per-flow Bearer token. - * - `hmac_sha256`: HMAC-SHA256 signature verification against the raw body. + * - `hmac`: template-based HMAC verification. Requires either + * `--preset=` (registered via the + * `datamachine_webhook_auth_presets` filter) or + * `--config=@file.json` (an explicit template). + * + * Core ships zero presets. Run `wp datamachine flows webhook presets` + * to see what has been registered on this install. * * ## OPTIONS * @@ -77,56 +93,53 @@ public function dispatch( array $args, array $assoc_args ): void { * : The flow ID to enable webhook trigger for. * * [--auth-mode=] - * : Authentication mode. + * : Authentication primitive. * --- * default: bearer * options: * - bearer - * - hmac_sha256 + * - hmac * --- * - * [--signature-header=
] - * : HMAC signature header name (e.g. X-Hub-Signature-256, Stripe-Signature, X-Shopify-Hmac-Sha256). Only used when --auth-mode=hmac_sha256. + * [--preset=] + * : Name of a preset registered via `datamachine_webhook_auth_presets`. + * Implies HMAC mode. Expands server-side to a full template. * - * [--signature-format=] - * : HMAC signature encoding. Only used when --auth-mode=hmac_sha256. - * --- - * default: sha256=hex - * options: - * - sha256=hex - * - hex - * - base64 - * --- + * [--config=] + * : Path to a JSON file containing an explicit template config. Use @- + * prefix or plain path. Implies HMAC mode. + * + * [--overrides=] + * : Path to a JSON file with deep-merge overrides applied on top of the + * preset or template. * * [--generate-secret] * : Generate a random 32-byte hex secret for HMAC mode. * * [--secret=] - * : Use this explicit HMAC secret (e.g. the value you configured on the upstream service). + * : Use this explicit HMAC secret (takes precedence over --generate-secret). + * + * [--secret-id=] + * : Secret id for multi-secret rotation (default: current). * * ## EXAMPLES * * # Enable with default Bearer auth * wp datamachine flows webhook enable 42 * - * # Enable with HMAC-SHA256 auth and a generated secret (GitHub-style) - * wp datamachine flows webhook enable 42 --auth-mode=hmac_sha256 --generate-secret + * # Enable via a preset (provider-agnostic; preset names come from filters) + * wp datamachine flows webhook enable 42 --preset=my-preset --generate-secret * - * # Enable with HMAC-SHA256 auth and an explicit secret - * wp datamachine flows webhook enable 42 --auth-mode=hmac_sha256 --secret=abc123... - * - * # Enable with HMAC-SHA256 auth for Shopify (base64 header) + * # Enable with an explicit template and a known secret * wp datamachine flows webhook enable 42 \ - * --auth-mode=hmac_sha256 \ - * --signature-header=X-Shopify-Hmac-Sha256 \ - * --signature-format=base64 \ - * --secret= + * --config=@template.json \ + * --secret= * * @subcommand enable */ public function enable( array $args, array $assoc_args ): void { if ( empty( $args ) ) { - WP_CLI::error( 'Usage: wp datamachine flows webhook enable [--auth-mode=] [--signature-header=
] [--signature-format=] [--generate-secret] [--secret=]' ); + WP_CLI::error( 'Usage: wp datamachine flows webhook enable [--auth-mode=] [--preset=] [--config=] [--overrides=] [--generate-secret] [--secret=] [--secret-id=]' ); return; } @@ -141,11 +154,22 @@ public function enable( array $args, array $assoc_args ): void { if ( isset( $assoc_args['auth-mode'] ) ) { $input['auth_mode'] = (string) $assoc_args['auth-mode']; } - if ( isset( $assoc_args['signature-header'] ) ) { - $input['signature_header'] = (string) $assoc_args['signature-header']; + if ( isset( $assoc_args['preset'] ) ) { + $input['preset'] = (string) $assoc_args['preset']; } - if ( isset( $assoc_args['signature-format'] ) ) { - $input['signature_format'] = (string) $assoc_args['signature-format']; + if ( isset( $assoc_args['config'] ) ) { + $template = self::read_json_file( (string) $assoc_args['config'], 'config' ); + if ( null === $template ) { + return; + } + $input['template'] = $template; + } + if ( isset( $assoc_args['overrides'] ) ) { + $overrides = self::read_json_file( (string) $assoc_args['overrides'], 'overrides' ); + if ( null === $overrides ) { + return; + } + $input['template_overrides'] = $overrides; } if ( ! empty( $assoc_args['generate-secret'] ) ) { $input['generate_secret'] = true; @@ -153,11 +177,14 @@ public function enable( array $args, array $assoc_args ): void { if ( isset( $assoc_args['secret'] ) ) { $input['secret'] = (string) $assoc_args['secret']; } + if ( isset( $assoc_args['secret-id'] ) ) { + $input['secret_id'] = (string) $assoc_args['secret-id']; + } $ability = new \DataMachine\Abilities\Flow\WebhookTriggerAbility(); $result = $ability->executeEnable( $input ); - if ( ! $result['success'] ) { + if ( empty( $result['success'] ) ) { WP_CLI::error( $result['error'] ?? 'Failed to enable webhook trigger' ); return; } @@ -168,19 +195,7 @@ public function enable( array $args, array $assoc_args ): void { WP_CLI::log( sprintf( 'URL: %s', $result['webhook_url'] ) ); WP_CLI::log( sprintf( 'Auth mode: %s', $auth_mode ) ); - if ( 'hmac_sha256' === $auth_mode ) { - WP_CLI::log( sprintf( 'Header: %s', $result['signature_header'] ?? 'X-Hub-Signature-256' ) ); - WP_CLI::log( sprintf( 'Format: %s', $result['signature_format'] ?? 'sha256=hex' ) ); - - if ( isset( $result['secret'] ) ) { - WP_CLI::log( sprintf( 'Secret: %s', $result['secret'] ) ); - WP_CLI::warning( 'Save this secret now — it will not be shown again.' ); - WP_CLI::log( '' ); - WP_CLI::log( 'Paste this secret into your webhook provider (e.g. GitHub → Settings → Webhooks → Secret).' ); - } else { - WP_CLI::log( 'Secret: (unchanged — use `set-secret` to rotate)' ); - } - } else { + if ( 'bearer' === $auth_mode ) { WP_CLI::log( sprintf( 'Token: %s', $result['token'] ) ); WP_CLI::log( '' ); WP_CLI::log( 'Usage:' ); @@ -188,14 +203,63 @@ public function enable( array $args, array $assoc_args ): void { WP_CLI::log( sprintf( ' -H "Authorization: Bearer %s" \\', $result['token'] ) ); WP_CLI::log( ' -H "Content-Type: application/json" \\' ); WP_CLI::log( ' -d \'{"key": "value"}\'' ); + return; + } + + // HMAC output. + if ( isset( $result['secret'] ) ) { + WP_CLI::log( sprintf( 'Secret: %s', $result['secret'] ) ); + WP_CLI::warning( 'Save this secret now — it will not be shown again.' ); + WP_CLI::log( '' ); + WP_CLI::log( 'Paste this secret into the upstream provider configuration.' ); + } else { + WP_CLI::log( 'Secret: (unchanged — use `set-secret` or `rotate` to change)' ); + } + + if ( ! empty( $result['secret_ids'] ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Active secret ids:' ); + foreach ( $result['secret_ids'] as $entry ) { + $line = ' - ' . ( $entry['id'] ?? '' ); + if ( ! empty( $entry['expires_at'] ) ) { + $line .= ' (expires ' . $entry['expires_at'] . ')'; + } + WP_CLI::log( $line ); + } } } /** - * Set or rotate the HMAC shared secret for a flow. + * Load + parse a JSON file referenced by `--config=@path` or `--config=path`. + * Returns null on error (after printing a CLI error). + * + * @param string $raw + * @param string $label Used in error messages. + * @return array|null + */ + private static function read_json_file( string $raw, string $label ): ?array { + $path = 0 === strpos( $raw, '@' ) ? substr( $raw, 1 ) : $raw; + if ( ! is_readable( $path ) ) { + WP_CLI::error( sprintf( 'Cannot read %s file: %s', $label, $path ) ); + return null; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents( $path ); + $decoded = json_decode( (string) $content, true ); + if ( ! is_array( $decoded ) ) { + WP_CLI::error( sprintf( '%s file is not valid JSON: %s', ucfirst( $label ), $path ) ); + return null; + } + return $decoded; + } + + /** + * Set or replace the HMAC shared secret for an existing HMAC flow. + * + * The flow must already be in HMAC mode — run `enable --preset=` + * or `enable --config=@template.json` first. * - * Switches the flow to hmac_sha256 auth mode if it isn't already. - * Provide exactly one of --secret or --generate. + * Prefer `rotate` over `set-secret` when you need a grace window. * * ## OPTIONS * @@ -208,12 +272,12 @@ public function enable( array $args, array $assoc_args ): void { * [--generate] * : Generate a random 32-byte hex secret and print it once. * + * [--secret-id=] + * : Secret id (default: current). Use `rotate` for zero-downtime swaps. + * * ## EXAMPLES * - * # Paste a secret from GitHub * wp datamachine flows webhook set-secret 42 --secret= - * - * # Generate a fresh secret (you will paste it into the provider) * wp datamachine flows webhook set-secret 42 --generate * * @subcommand set-secret @@ -248,18 +312,21 @@ public function set_secret( array $args, array $assoc_args ): void { } else { $input['generate'] = true; } + if ( isset( $assoc_args['secret-id'] ) ) { + $input['secret_id'] = (string) $assoc_args['secret-id']; + } $ability = new \DataMachine\Abilities\Flow\WebhookTriggerAbility(); $result = $ability->executeSetSecret( $input ); - if ( ! $result['success'] ) { + if ( empty( $result['success'] ) ) { WP_CLI::error( $result['error'] ?? 'Failed to set webhook secret' ); return; } WP_CLI::success( $result['message'] ); WP_CLI::log( sprintf( 'Flow: %d', $flow_id ) ); - WP_CLI::log( sprintf( 'Auth mode: %s', $result['auth_mode'] ?? 'hmac_sha256' ) ); + WP_CLI::log( sprintf( 'Auth mode: %s', $result['auth_mode'] ?? 'hmac' ) ); WP_CLI::log( sprintf( 'Secret: %s', $result['secret'] ) ); WP_CLI::warning( 'Save this secret now — it will not be shown again.' ); } @@ -406,15 +473,25 @@ public function status( array $args, array $assoc_args ): void { WP_CLI::log( sprintf( 'Webhook: %s', $result['webhook_enabled'] ? 'enabled' : 'disabled' ) ); if ( $result['webhook_enabled'] ) { + $auth_mode = $result['auth_mode'] ?? 'bearer'; WP_CLI::log( sprintf( 'URL: %s', $result['webhook_url'] ) ); - WP_CLI::log( sprintf( 'Auth mode: %s', $result['auth_mode'] ?? 'bearer' ) ); + WP_CLI::log( sprintf( 'Auth mode: %s', $auth_mode ) ); WP_CLI::log( sprintf( 'Created: %s', $result['created_at'] ?? 'unknown' ) ); - if ( 'hmac_sha256' === ( $result['auth_mode'] ?? 'bearer' ) ) { - WP_CLI::log( sprintf( 'Header: %s', $result['signature_header'] ?? 'X-Hub-Signature-256' ) ); - WP_CLI::log( sprintf( 'Format: %s', $result['signature_format'] ?? 'sha256=hex' ) ); - if ( isset( $result['max_body_bytes'] ) ) { - WP_CLI::log( sprintf( 'Max body: %d bytes', (int) $result['max_body_bytes'] ) ); + if ( 'bearer' !== $auth_mode ) { + if ( ! empty( $result['template'] ) ) { + WP_CLI::log( 'Template:' ); + WP_CLI::log( (string) wp_json_encode( $result['template'], JSON_PRETTY_PRINT ) ); + } + if ( ! empty( $result['secret_ids'] ) ) { + WP_CLI::log( 'Secrets:' ); + foreach ( $result['secret_ids'] as $entry ) { + $line = ' - ' . ( $entry['id'] ?? '' ); + if ( ! empty( $entry['expires_at'] ) ) { + $line .= ' (expires ' . $entry['expires_at'] . ')'; + } + WP_CLI::log( $line ); + } } } } @@ -455,16 +532,20 @@ public function list_webhooks( array $args, array $assoc_args ): void { $webhook_flows = array(); foreach ( $flows as $flow ) { - $config = $flow['scheduling_config'] ?? array(); - if ( ! empty( $config['webhook_enabled'] ) ) { - $webhook_flows[] = array( - 'flow_id' => $flow['flow_id'], - 'flow_name' => $flow['flow_name'], - 'auth_mode' => $config['webhook_auth_mode'] ?? 'bearer', - 'webhook_url' => \DataMachine\Abilities\Flow\WebhookTriggerAbility::get_webhook_url( (int) $flow['flow_id'] ), - 'created_at' => $config['webhook_created_at'] ?? '', - ); + $raw_config = $flow['scheduling_config'] ?? array(); + if ( empty( $raw_config['webhook_enabled'] ) ) { + continue; } + // Normalise auth_mode label for list display (v1 → v2 on the fly). + $migration = \DataMachine\Api\WebhookAuthResolver::migrate_legacy( $raw_config ); + $config = $migration['config']; + $webhook_flows[] = array( + 'flow_id' => $flow['flow_id'], + 'flow_name' => $flow['flow_name'], + 'auth_mode' => $config['webhook_auth_mode'] ?? 'bearer', + 'webhook_url' => \DataMachine\Abilities\Flow\WebhookTriggerAbility::get_webhook_url( (int) $flow['flow_id'] ), + 'created_at' => $config['webhook_created_at'] ?? '', + ); } if ( empty( $webhook_flows ) ) { @@ -564,4 +645,196 @@ public function rate_limit( array $args, array $assoc_args ): void { WP_CLI::success( $result['message'] ); } + + /** + * Rotate the HMAC shared secret with a grace period. + * + * Demotes `current` → `previous` (keeps verifying for --previous-ttl-seconds, + * default 7 days), installs a fresh `current`. Zero-downtime swap window: + * rotate here, update the upstream provider, then `forget previous`. + * + * ## OPTIONS + * + * + * : The flow ID to rotate the secret for. + * + * [--secret=] + * : Explicit new secret value (takes precedence over --generate). + * + * [--generate] + * : Generate a random 32-byte hex secret. + * + * [--previous-ttl-seconds=] + * : How long the old secret keeps verifying (default: 604800 = 7 days). + * + * ## EXAMPLES + * + * wp datamachine flows webhook rotate 42 --generate + * wp datamachine flows webhook rotate 42 --secret= --previous-ttl-seconds=86400 + * + * @subcommand rotate + */ + public function rotate( array $args, array $assoc_args ): void { + if ( empty( $args ) ) { + WP_CLI::error( 'Usage: wp datamachine flows webhook rotate (--secret= | --generate) [--previous-ttl-seconds=]' ); + return; + } + $flow_id = (int) $args[0]; + if ( $flow_id <= 0 ) { + WP_CLI::error( 'flow_id must be a positive integer' ); + return; + } + + $has_secret = isset( $assoc_args['secret'] ); + $has_generate = ! empty( $assoc_args['generate'] ); + if ( ! $has_secret && ! $has_generate ) { + WP_CLI::error( 'Provide exactly one of --secret= or --generate.' ); + return; + } + if ( $has_secret && $has_generate ) { + WP_CLI::error( 'Pass either --secret= or --generate, not both.' ); + return; + } + + $input = array( 'flow_id' => $flow_id ); + if ( $has_secret ) { + $input['secret'] = (string) $assoc_args['secret']; + } else { + $input['generate'] = true; + } + if ( isset( $assoc_args['previous-ttl-seconds'] ) ) { + $input['previous_ttl_seconds'] = (int) $assoc_args['previous-ttl-seconds']; + } + + $ability = new \DataMachine\Abilities\Flow\WebhookTriggerAbility(); + $result = $ability->executeRotateSecret( $input ); + + if ( empty( $result['success'] ) ) { + WP_CLI::error( $result['error'] ?? 'Failed to rotate secret' ); + return; + } + + WP_CLI::success( $result['message'] ); + WP_CLI::log( sprintf( 'New secret: %s', $result['new_secret'] ) ); + WP_CLI::log( sprintf( 'Previous expires at: %s', $result['previous_expires_at'] ) ); + WP_CLI::warning( 'Save this secret now — it will not be shown again.' ); + + if ( ! empty( $result['secret_ids'] ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Active secret ids:' ); + foreach ( $result['secret_ids'] as $entry ) { + $line = ' - ' . ( $entry['id'] ?? '' ); + if ( ! empty( $entry['expires_at'] ) ) { + $line .= ' (expires ' . $entry['expires_at'] . ')'; + } + WP_CLI::log( $line ); + } + } + } + + /** + * Forget a specific secret by id. + * + * Removes the secret from the rotation list immediately. Typical use: + * `forget previous` after the upstream provider has been updated. + * + * ## OPTIONS + * + * + * : The flow ID. + * + * + * : The secret id to forget (e.g. `previous`). + * + * ## EXAMPLES + * + * wp datamachine flows webhook forget 42 previous + * + * @subcommand forget + */ + public function forget( array $args, array $assoc_args ): void { + if ( count( $args ) < 2 ) { + WP_CLI::error( 'Usage: wp datamachine flows webhook forget ' ); + return; + } + $flow_id = (int) $args[0]; + $secret_id = (string) $args[1]; + + if ( $flow_id <= 0 ) { + WP_CLI::error( 'flow_id must be a positive integer' ); + return; + } + + $ability = new \DataMachine\Abilities\Flow\WebhookTriggerAbility(); + $result = $ability->executeForgetSecret( + array( + 'flow_id' => $flow_id, + 'secret_id' => $secret_id, + ) + ); + + if ( empty( $result['success'] ) ) { + WP_CLI::error( $result['error'] ?? 'Failed to forget secret' ); + return; + } + + WP_CLI::success( $result['message'] ); + } + + /** + * List webhook auth presets registered via the + * `datamachine_webhook_auth_presets` filter. + * + * Core ships zero presets — they come from companion plugins or site + * mu-plugins. This command simply inventories what's registered on the + * current install. + * + * ## OPTIONS + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * wp datamachine flows webhook presets + * + * @subcommand presets + */ + public function presets( array $args, array $assoc_args ): void { + $presets = \DataMachine\Api\WebhookAuthResolver::get_presets(); + if ( empty( $presets ) ) { + WP_CLI::log( 'No presets registered. Add presets via the datamachine_webhook_auth_presets filter.' ); + return; + } + + $rows = array(); + foreach ( $presets as $name => $cfg ) { + $sig = $cfg['signature_source'] ?? array(); + $ts = $cfg['timestamp_source'] ?? array(); + $rows[] = array( + 'name' => (string) $name, + 'mode' => (string) ( $cfg['mode'] ?? 'hmac' ), + 'algo' => (string) ( $cfg['algo'] ?? 'sha256' ), + 'signed_template' => (string) ( $cfg['signed_template'] ?? '{body}' ), + 'signature_header' => (string) ( $sig['header'] ?? ( $sig['param'] ?? '' ) ), + 'encoding' => (string) ( $sig['encoding'] ?? '' ), + 'has_timestamp' => $ts ? 'yes' : 'no', + 'replay_tolerance' => isset( $cfg['tolerance_seconds'] ) ? (string) (int) $cfg['tolerance_seconds'] : '', + ); + } + + $this->format_items( + $rows, + array( 'name', 'mode', 'signed_template', 'signature_header', 'encoding', 'has_timestamp', 'replay_tolerance' ), + $assoc_args, + 'name' + ); + } } diff --git a/tests/Unit/Api/WebhookAuthResolverTest.php b/tests/Unit/Api/WebhookAuthResolverTest.php new file mode 100644 index 000000000..db5cedb34 --- /dev/null +++ b/tests/Unit/Api/WebhookAuthResolverTest.php @@ -0,0 +1,181 @@ +assertSame( 'bearer', $out['mode'] ); + $this->assertNull( $out['verifier'] ); + } + + public function test_bearer_returns_token(): void { + $out = WebhookAuthResolver::resolve( array( + 'webhook_auth_mode' => 'bearer', + 'webhook_token' => 'abc', + ) ); + $this->assertSame( 'bearer', $out['mode'] ); + $this->assertSame( 'abc', $out['token'] ); + } + + public function test_hmac_with_template_passes_through(): void { + $template = array( + 'mode' => 'hmac', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Sig', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ), + ); + $out = WebhookAuthResolver::resolve( array( + 'webhook_auth_mode' => 'hmac', + 'webhook_auth' => $template, + 'webhook_secrets' => array( array( 'id' => 'current', 'value' => 'abc' ) ), + ) ); + + $this->assertSame( 'hmac', $out['mode'] ); + $this->assertSame( '{body}', $out['verifier']['signed_template'] ); + $this->assertSame( 'abc', $out['verifier']['secrets'][0]['value'] ); + } + + public function test_hmac_without_template_returns_null_verifier(): void { + $out = WebhookAuthResolver::resolve( array( + 'webhook_auth_mode' => 'hmac', + // no webhook_auth block — this is a misconfigured flow, caller should 401 it. + ) ); + $this->assertSame( 'hmac', $out['mode'] ); + $this->assertNull( $out['verifier'] ); + } + + /* -------- migrate_legacy() -------- */ + + public function test_migrate_noop_for_bearer_flow(): void { + $in = array( + 'webhook_enabled' => true, + 'webhook_auth_mode' => 'bearer', + 'webhook_token' => 'tok', + ); + $out = WebhookAuthResolver::migrate_legacy( $in ); + $this->assertFalse( $out['migrated'] ); + $this->assertSame( $in, $out['config'] ); + } + + public function test_migrate_noop_for_canonical_hmac_flow(): void { + $in = array( + 'webhook_enabled' => true, + 'webhook_auth_mode' => 'hmac', + 'webhook_auth' => array( 'mode' => 'hmac' ), + 'webhook_secrets' => array(), + ); + $out = WebhookAuthResolver::migrate_legacy( $in ); + $this->assertFalse( $out['migrated'] ); + } + + public function test_migrate_v1_hmac_sha256_flow_to_v2(): void { + $in = array( + 'webhook_enabled' => true, + 'webhook_auth_mode' => 'hmac_sha256', + 'webhook_signature_header' => 'X-Hub-Signature-256', + 'webhook_signature_format' => 'sha256=hex', + 'webhook_secret' => 'legacy-secret', + ); + $out = WebhookAuthResolver::migrate_legacy( $in ); + + $this->assertTrue( $out['migrated'] ); + $config = $out['config']; + + $this->assertSame( 'hmac', $config['webhook_auth_mode'] ); + $this->assertArrayNotHasKey( 'webhook_signature_header', $config ); + $this->assertArrayNotHasKey( 'webhook_signature_format', $config ); + $this->assertArrayNotHasKey( 'webhook_secret', $config ); + + $this->assertArrayHasKey( 'webhook_auth', $config ); + $this->assertSame( '{body}', $config['webhook_auth']['signed_template'] ); + $this->assertSame( 'X-Hub-Signature-256', $config['webhook_auth']['signature_source']['header'] ); + $this->assertSame( 'prefix', $config['webhook_auth']['signature_source']['extract']['kind'] ); + $this->assertSame( 'sha256=', $config['webhook_auth']['signature_source']['extract']['key'] ); + $this->assertSame( 'hex', $config['webhook_auth']['signature_source']['encoding'] ); + + $this->assertArrayHasKey( 'webhook_secrets', $config ); + $this->assertSame( 'current', $config['webhook_secrets'][0]['id'] ); + $this->assertSame( 'legacy-secret', $config['webhook_secrets'][0]['value'] ); + } + + public function test_migrate_v1_base64_format(): void { + $in = array( + 'webhook_enabled' => true, + 'webhook_auth_mode' => 'hmac_sha256', + 'webhook_signature_header' => 'X-Shopify-Hmac-Sha256', + 'webhook_signature_format' => 'base64', + 'webhook_secret' => 'x', + ); + $out = WebhookAuthResolver::migrate_legacy( $in ); + $this->assertTrue( $out['migrated'] ); + $this->assertSame( 'base64', $out['config']['webhook_auth']['signature_source']['encoding'] ); + $this->assertSame( 'raw', $out['config']['webhook_auth']['signature_source']['extract']['kind'] ); + } + + public function test_migrate_drops_orphan_legacy_fields(): void { + // Fields left over from a partial migration but no legacy mode set. + $in = array( + 'webhook_enabled' => true, + 'webhook_auth_mode' => 'hmac', + 'webhook_auth' => array( 'mode' => 'hmac' ), + 'webhook_signature_header' => 'stale', + 'webhook_secret' => 'stale', + ); + $out = WebhookAuthResolver::migrate_legacy( $in ); + $this->assertTrue( $out['migrated'] ); + $this->assertArrayNotHasKey( 'webhook_signature_header', $out['config'] ); + $this->assertArrayNotHasKey( 'webhook_secret', $out['config'] ); + } + + /* -------- presets -------- */ + + public function test_get_presets_empty_by_default(): void { + $this->assertSame( array(), WebhookAuthResolver::get_presets() ); + } + + public function test_presets_registered_via_filter(): void { + add_filter( 'datamachine_webhook_auth_presets', function ( $p ) { + $p['example'] = array( 'mode' => 'hmac' ); + return $p; + } ); + $presets = WebhookAuthResolver::get_presets(); + $this->assertArrayHasKey( 'example', $presets ); + } + + public function test_deep_merge_preserves_base_keys(): void { + $base = array( + 'signature_source' => array( 'header' => 'X-Default', 'encoding' => 'hex' ), + 'tolerance_seconds' => 300, + ); + $override = array( + 'signature_source' => array( 'header' => 'X-Override' ), + ); + $out = WebhookAuthResolver::deep_merge( $base, $override ); + $this->assertSame( 'X-Override', $out['signature_source']['header'] ); + $this->assertSame( 'hex', $out['signature_source']['encoding'] ); // preserved + $this->assertSame( 300, $out['tolerance_seconds'] ); + } +} diff --git a/tests/Unit/Api/WebhookTriggerTest.php b/tests/Unit/Api/WebhookTriggerTest.php index f91a32931..7d6775246 100644 --- a/tests/Unit/Api/WebhookTriggerTest.php +++ b/tests/Unit/Api/WebhookTriggerTest.php @@ -1,9 +1,15 @@ user->create( array( 'role' => 'administrator' ) ); wp_set_current_user( $user_id ); - $pipeline = wp_get_ability( 'datamachine/create-pipeline' ) - ->execute( array( 'pipeline_name' => 'WebhookTrigger test pipeline' ) ); - $this->pipeline_id = (int) $pipeline['pipeline_id']; - - $flow = wp_get_ability( 'datamachine/create-flow' ) - ->execute( array( 'pipeline_id' => $this->pipeline_id, 'flow_name' => 'WebhookTrigger test flow' ) ); + $pipeline = wp_get_ability( 'datamachine/create-pipeline' )->execute( array( 'pipeline_name' => 'Test Pipeline' ) ); + $flow = wp_get_ability( 'datamachine/create-flow' )->execute( array( + 'pipeline_id' => (int) $pipeline['pipeline_id'], + 'flow_name' => 'Test Flow', + ) ); $this->flow_id = (int) $flow['flow_id']; - - $this->webhook_ability = new WebhookTriggerAbility(); + $this->ability = new WebhookTriggerAbility(); } public function tear_down(): void { + remove_all_filters( 'datamachine_webhook_auth_presets' ); delete_transient( 'dm_webhook_rate_' . $this->flow_id ); parent::tear_down(); } - /* ----------------------------------------------------------------- - * Ability-level behavior - * ----------------------------------------------------------------- + /* ================================================================= + * Ability surface + * ================================================================= */ - public function test_enable_defaults_to_bearer_mode(): void { - $result = $this->webhook_ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); - + public function test_enable_defaults_to_bearer(): void { + $result = $this->ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); $this->assertTrue( $result['success'] ); $this->assertSame( 'bearer', $result['auth_mode'] ); $this->assertNotEmpty( $result['token'] ); - $this->assertArrayNotHasKey( 'secret', $result ); } - public function test_enable_with_hmac_generates_secret(): void { - $result = $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) - ); + public function test_enable_hmac_requires_preset_or_template(): void { + $result = $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'auth_mode' => 'hmac', + ) ); + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'preset', $result['error'] ); + } + public function test_enable_hmac_with_unknown_preset_errors(): void { + $result = $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'does-not-exist', + ) ); + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'Unknown preset', $result['error'] ); + } + + public function test_enable_hmac_with_preset_generates_secret(): void { + $this->register_example_preset(); + $result = $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'example', + 'generate_secret' => true, + ) ); $this->assertTrue( $result['success'] ); - $this->assertSame( 'hmac_sha256', $result['auth_mode'] ); + $this->assertSame( 'hmac', $result['auth_mode'] ); $this->assertNotEmpty( $result['secret'] ); - $this->assertSame( 'X-Hub-Signature-256', $result['signature_header'] ); - $this->assertSame( 'sha256=hex', $result['signature_format'] ); + + // The stored config carries the full resolved template (no preset name leaks in). + $config = $this->get_scheduling_config(); + $this->assertSame( 'hmac', $config['webhook_auth_mode'] ); + $this->assertSame( '{body}', $config['webhook_auth']['signed_template'] ); + $this->assertArrayNotHasKey( 'webhook_auth_preset', $config, 'preset name must not leak into stored config' ); } - public function test_enable_with_hmac_accepts_explicit_secret_and_custom_header(): void { - $result = $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'secret' => 'explicit-shopify-secret', - 'signature_header' => 'X-Shopify-Hmac-Sha256', - 'signature_format' => 'base64', - ) + public function test_enable_hmac_with_explicit_template(): void { + $template = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Sig', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ), ); - + $result = $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'template' => $template, + 'generate_secret' => true, + ) ); $this->assertTrue( $result['success'] ); - $this->assertSame( 'explicit-shopify-secret', $result['secret'] ); - $this->assertSame( 'X-Shopify-Hmac-Sha256', $result['signature_header'] ); - $this->assertSame( 'base64', $result['signature_format'] ); + $this->assertSame( 'hmac', $result['auth_mode'] ); } - public function test_status_never_returns_secret(): void { - $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) - ); + public function test_enable_hmac_template_overrides_deep_merge(): void { + $this->register_example_preset(); + $result = $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'example', + 'generate_secret' => true, + 'template_overrides' => array( + 'tolerance_seconds' => 60, + ), + ) ); + $this->assertTrue( $result['success'] ); + $config = $this->get_scheduling_config(); + $this->assertSame( 60, $config['webhook_auth']['tolerance_seconds'] ); + } - $status = $this->webhook_ability->executeStatus( array( 'flow_id' => $this->flow_id ) ); + public function test_status_never_returns_secret_values(): void { + $this->register_example_preset(); + $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'example', + 'generate_secret' => true, + ) ); + $status = $this->ability->executeStatus( array( 'flow_id' => $this->flow_id ) ); $this->assertTrue( $status['success'] ); - $this->assertTrue( $status['webhook_enabled'] ); - $this->assertSame( 'hmac_sha256', $status['auth_mode'] ); - $this->assertSame( 'X-Hub-Signature-256', $status['signature_header'] ); - $this->assertSame( 'sha256=hex', $status['signature_format'] ); + $this->assertSame( 'hmac', $status['auth_mode'] ); + $this->assertArrayHasKey( 'template', $status ); + $this->assertArrayHasKey( 'secret_ids', $status ); $this->assertArrayNotHasKey( 'secret', $status ); $this->assertArrayNotHasKey( 'webhook_secret', $status ); - } - public function test_set_secret_rotates_and_switches_to_hmac(): void { - $this->webhook_ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); // bearer - - $result = $this->webhook_ability->executeSetSecret( - array( - 'flow_id' => $this->flow_id, - 'generate' => true, - ) - ); - - $this->assertTrue( $result['success'] ); - $this->assertSame( 'hmac_sha256', $result['auth_mode'] ); - $this->assertNotEmpty( $result['secret'] ); - - $status = $this->webhook_ability->executeStatus( array( 'flow_id' => $this->flow_id ) ); - $this->assertSame( 'hmac_sha256', $status['auth_mode'] ); + $encoded = wp_json_encode( $status ); + $this->assertStringNotContainsString( '"value"', $encoded ); } - public function test_set_secret_requires_input(): void { - $result = $this->webhook_ability->executeSetSecret( array( 'flow_id' => $this->flow_id ) ); + public function test_set_secret_rejects_flow_without_template(): void { + // Enable in bearer mode so there's no HMAC template yet. + $this->ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); + $result = $this->ability->executeSetSecret( array( + 'flow_id' => $this->flow_id, + 'generate' => true, + ) ); $this->assertFalse( $result['success'] ); - $this->assertStringContainsString( 'secret', $result['error'] ); + $this->assertStringContainsString( 'template', $result['error'] ); } - public function test_regenerate_rejects_hmac_mode(): void { - $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) - ); - - $result = $this->webhook_ability->executeRegenerate( array( 'flow_id' => $this->flow_id ) ); - - $this->assertFalse( $result['success'] ); - $this->assertStringContainsString( 'bearer', $result['error'] ); + public function test_rotate_keeps_previous_secret_verifying(): void { + $this->register_example_preset(); + $enable = $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'example', + 'generate_secret' => true, + ) ); + $old_secret = $enable['secret']; + + $rotated = $this->ability->executeRotateSecret( array( + 'flow_id' => $this->flow_id, + 'generate' => true, + 'previous_ttl_seconds' => 3600, + ) ); + $this->assertTrue( $rotated['success'] ); + + // A request signed with the OLD secret still verifies during the grace window. + $body = '{"x":1}'; + $sig = 'sha256=' . hash_hmac( 'sha256', $body, $old_secret ); + $res = WebhookTrigger::handle_trigger( $this->make_request( $body, array( 'x-hub-signature-256' => $sig ) ) ); + $this->assert_not_unauthorized( $res ); + + // And one signed with the NEW secret also verifies. + $new_sig = 'sha256=' . hash_hmac( 'sha256', $body, $rotated['new_secret'] ); + $res = WebhookTrigger::handle_trigger( $this->make_request( $body, array( 'x-hub-signature-256' => $new_sig ) ) ); + $this->assert_not_unauthorized( $res ); } - public function test_disable_clears_hmac_fields(): void { - $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) - ); - - $this->webhook_ability->executeDisable( array( 'flow_id' => $this->flow_id ) ); - - $config = $this->get_scheduling_config(); - $this->assertArrayNotHasKey( 'webhook_secret', $config ); - $this->assertArrayNotHasKey( 'webhook_auth_mode', $config ); - $this->assertArrayNotHasKey( 'webhook_signature_header', $config ); + public function test_forget_previous_immediately_invalidates(): void { + $this->register_example_preset(); + $enable = $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'example', + 'generate_secret' => true, + ) ); + $old_secret = $enable['secret']; + + $this->ability->executeRotateSecret( array( + 'flow_id' => $this->flow_id, + 'generate' => true, + 'previous_ttl_seconds' => 3600, + ) ); + + $this->ability->executeForgetSecret( array( + 'flow_id' => $this->flow_id, + 'secret_id' => 'previous', + ) ); + + $body = '{"x":1}'; + $sig = 'sha256=' . hash_hmac( 'sha256', $body, $old_secret ); + $res = WebhookTrigger::handle_trigger( $this->make_request( $body, array( 'x-hub-signature-256' => $sig ) ) ); + $this->assert_is_unauthorized( $res ); } - /* ----------------------------------------------------------------- - * handle_trigger() — Bearer regression path - * ----------------------------------------------------------------- + /* ================================================================= + * REST handler — end to end + * ================================================================= */ public function test_bearer_flow_still_works(): void { - $result = $this->webhook_ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); - $token = $result['token']; + $enable = $this->ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); + $token = $enable['token']; - $request = $this->make_request( array(), array( 'Authorization' => 'Bearer ' . $token ) ); - $response = WebhookTrigger::handle_trigger( $request ); - - $this->assert_not_unauthorized( $response ); - } - - public function test_bearer_missing_token_returns_401(): void { - $this->webhook_ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); - - $request = $this->make_request(); - $response = WebhookTrigger::handle_trigger( $request ); - - $this->assert_is_unauthorized( $response ); + $res = WebhookTrigger::handle_trigger( $this->make_request( '', array( 'authorization' => 'Bearer ' . $token ) ) ); + $this->assert_not_unauthorized( $res ); } public function test_bearer_wrong_token_returns_401(): void { - $this->webhook_ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); - - $request = $this->make_request( - array(), - array( 'Authorization' => 'Bearer ' . str_repeat( 'a', 64 ) ) - ); - $response = WebhookTrigger::handle_trigger( $request ); - - $this->assert_is_unauthorized( $response ); + $this->ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); + $res = WebhookTrigger::handle_trigger( $this->make_request( '', array( 'authorization' => 'Bearer ' . str_repeat( 'a', 64 ) ) ) ); + $this->assert_is_unauthorized( $res ); } - /* ----------------------------------------------------------------- - * handle_trigger() — HMAC-SHA256 path - * ----------------------------------------------------------------- - */ - public function test_hmac_valid_signature_passes(): void { - $enable = $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) - ); + $this->register_example_preset(); + $enable = $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'example', + 'generate_secret' => true, + ) ); $secret = $enable['secret']; - $body = '{"action":"opened","number":1}'; - $sig = 'sha256=' . hash_hmac( 'sha256', $body, $secret ); - - $request = $this->make_request_raw( $body, array( 'X-Hub-Signature-256' => $sig ) ); - $response = WebhookTrigger::handle_trigger( $request ); - $this->assert_not_unauthorized( $response ); + $body = '{"action":"opened"}'; + $sig = 'sha256=' . hash_hmac( 'sha256', $body, $secret ); + $res = WebhookTrigger::handle_trigger( $this->make_request( $body, array( 'x-hub-signature-256' => $sig ) ) ); + $this->assert_not_unauthorized( $res ); } public function test_hmac_invalid_signature_returns_401(): void { - $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) - ); - - $body = '{"action":"opened"}'; - $bad = 'sha256=' . str_repeat( '0', 64 ); - - $request = $this->make_request_raw( $body, array( 'X-Hub-Signature-256' => $bad ) ); - $response = WebhookTrigger::handle_trigger( $request ); - - $this->assert_is_unauthorized( $response ); + $this->register_example_preset(); + $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'example', + 'generate_secret' => true, + ) ); + $res = WebhookTrigger::handle_trigger( $this->make_request( + '{"x":1}', + array( 'x-hub-signature-256' => 'sha256=' . str_repeat( '0', 64 ) ) + ) ); + $this->assert_is_unauthorized( $res ); } public function test_hmac_missing_signature_header_returns_401(): void { - $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) - ); - - $request = $this->make_request_raw( '{"x":1}' ); - $response = WebhookTrigger::handle_trigger( $request ); - - $this->assert_is_unauthorized( $response ); + $this->register_example_preset(); + $this->ability->executeEnable( array( + 'flow_id' => $this->flow_id, + 'preset' => 'example', + 'generate_secret' => true, + ) ); + $res = WebhookTrigger::handle_trigger( $this->make_request( '{"x":1}', array() ) ); + $this->assert_is_unauthorized( $res ); } - public function test_hmac_oversized_body_returns_413(): void { - $enable = $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) + public function test_hmac_without_template_returns_401_not_github_default(): void { + // Simulate a flow that claims HMAC mode but has no template — should + // NOT silently fall back to GitHub-style defaults; should cleanly 401. + $db = new Flows(); + $config = array( + 'webhook_enabled' => true, + 'webhook_auth_mode' => 'hmac', + 'webhook_secrets' => array( array( 'id' => 'current', 'value' => 'x' ) ), ); - $secret = $enable['secret']; - - // Lower the max to something tiny. - $config = $this->get_scheduling_config(); - $config['webhook_max_body_bytes'] = 16; - $this->update_scheduling_config( $config ); - - $body = str_repeat( 'a', 128 ); - $sig = 'sha256=' . hash_hmac( 'sha256', $body, $secret ); - - $request = $this->make_request_raw( $body, array( 'X-Hub-Signature-256' => $sig ) ); - $response = WebhookTrigger::handle_trigger( $request ); + $db->update_flow( $this->flow_id, array( 'scheduling_config' => $config ) ); - $this->assertInstanceOf( \WP_Error::class, $response ); - $this->assertSame( 'payload_too_large', $response->get_error_code() ); - $this->assertSame( 413, $response->get_error_data()['status'] ); + $body = '{"x":1}'; + $sig = 'sha256=' . hash_hmac( 'sha256', $body, 'x' ); + $res = WebhookTrigger::handle_trigger( $this->make_request( $body, array( 'x-hub-signature-256' => $sig ) ) ); + $this->assert_is_unauthorized( $res ); } - public function test_hmac_wrong_body_signature_rejected(): void { - $enable = $this->webhook_ability->executeEnable( - array( - 'flow_id' => $this->flow_id, - 'auth_mode' => 'hmac_sha256', - 'generate_secret' => true, - ) - ); - $secret = $enable['secret']; - - $signed_body = '{"a":1}'; - $sent_body = '{"a":2}'; // tampered - $sig = 'sha256=' . hash_hmac( 'sha256', $signed_body, $secret ); - - $request = $this->make_request_raw( $sent_body, array( 'X-Hub-Signature-256' => $sig ) ); - $response = WebhookTrigger::handle_trigger( $request ); - - $this->assert_is_unauthorized( $response ); - } + /* ================================================================= + * Silent v1 → v2 migration + * ================================================================= + */ - public function test_missing_auth_mode_defaults_to_bearer(): void { - // Manually enable webhook without setting webhook_auth_mode — mimics flows - // created before HMAC support landed. + public function test_v1_legacy_flow_migrates_silently_and_still_authenticates(): void { + // Set a flow to the legacy v1 shape directly in the DB, bypassing the ability. $db = new Flows(); + $secret = 'legacy-secret-value'; $config = array( - 'webhook_enabled' => true, - 'webhook_token' => WebhookTriggerAbility::generate_token(), - 'webhook_created_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), + 'webhook_enabled' => true, + 'webhook_auth_mode' => 'hmac_sha256', + 'webhook_signature_header' => 'X-Hub-Signature-256', + 'webhook_signature_format' => 'sha256=hex', + 'webhook_secret' => $secret, ); $db->update_flow( $this->flow_id, array( 'scheduling_config' => $config ) ); - $request = $this->make_request( array(), array( 'Authorization' => 'Bearer ' . $config['webhook_token'] ) ); - $response = WebhookTrigger::handle_trigger( $request ); - - $this->assert_not_unauthorized( $response ); + // First request — should succeed. + $body = '{"legacy":true}'; + $sig = 'sha256=' . hash_hmac( 'sha256', $body, $secret ); + $res = WebhookTrigger::handle_trigger( $this->make_request( $body, array( 'x-hub-signature-256' => $sig ) ) ); + $this->assert_not_unauthorized( $res ); + + // Config must now be in canonical v2 shape — legacy fields gone, v2 fields present. + $new = $this->get_scheduling_config(); + $this->assertSame( 'hmac', $new['webhook_auth_mode'] ); + $this->assertArrayHasKey( 'webhook_auth', $new ); + $this->assertArrayNotHasKey( 'webhook_signature_header', $new ); + $this->assertArrayNotHasKey( 'webhook_signature_format', $new ); + $this->assertArrayNotHasKey( 'webhook_secret', $new ); + + $this->assertArrayHasKey( 'webhook_secrets', $new ); + $this->assertSame( 'current', $new['webhook_secrets'][0]['id'] ); + $this->assertSame( $secret, $new['webhook_secrets'][0]['value'] ); } - /* ----------------------------------------------------------------- - * Helpers - * ----------------------------------------------------------------- + /* ================================================================= + * Safe headers — pattern-based deny-list, no provider names + * ================================================================= */ - private function make_request( array $body = array(), array $headers = array() ): WP_REST_Request { + public function test_safe_headers_strip_known_sensitive_patterns(): void { + $this->ability->executeEnable( array( 'flow_id' => $this->flow_id ) ); + // Bearer flow — get safe headers to verify the deny-list is pattern based. $request = new WP_REST_Request( 'POST', '/datamachine/v1/trigger/' . $this->flow_id ); - $request->set_url_params( array( 'flow_id' => $this->flow_id ) ); $request->set_param( 'flow_id', $this->flow_id ); + $request->set_header( 'authorization', 'Bearer x' ); + $request->set_header( 'cookie', 'session=abc' ); + $request->set_header( 'x-my-secret', 'hush' ); + $request->set_header( 'x-random-signature', 'hush' ); $request->set_header( 'content-type', 'application/json' ); - foreach ( $headers as $key => $value ) { - $request->set_header( $key, $value ); - } - if ( ! empty( $body ) ) { - $json = wp_json_encode( $body ); - $request->set_body( $json ); - } - return $request; + $request->set_header( 'x-github-event', 'push' ); + // Set the body so that the v2 path runs through the handler. + $request->set_body( '' ); + + $reflect = new \ReflectionClass( WebhookTrigger::class ); + $method = $reflect->getMethod( 'get_safe_headers' ); + $method->setAccessible( true ); + $out = $method->invoke( null, $request ); + + // Sensitive headers: filtered out. + $this->assertArrayNotHasKey( 'authorization', $out ); + $this->assertArrayNotHasKey( 'cookie', $out ); + $this->assertArrayNotHasKey( 'x-my-secret', $out ); + $this->assertArrayNotHasKey( 'x-random-signature', $out ); + + // Non-sensitive headers: kept. Provider-specific names like x-github-event + // are kept because they don't match the deny pattern — not because we + // hardcoded their names anywhere. + $this->assertArrayHasKey( 'content-type', $out ); + $this->assertArrayHasKey( 'x-github-event', $out ); } - private function make_request_raw( string $body, array $headers = array() ): WP_REST_Request { + /* ================================================================= + * Helpers + * ================================================================= + */ + + /** + * Register an example preset used by multiple tests. The preset is named + * `example` — deliberately generic — because DM core doesn't know about + * any particular provider. + */ + private function register_example_preset(): void { + add_filter( 'datamachine_webhook_auth_presets', function ( $p ) { + $p['example'] = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Hub-Signature-256', + 'extract' => array( 'kind' => 'prefix', 'key' => 'sha256=' ), + 'encoding' => 'hex', + ), + 'tolerance_seconds' => 300, + ); + return $p; + } ); + } + + private function make_request( string $body, array $headers ): WP_REST_Request { $request = new WP_REST_Request( 'POST', '/datamachine/v1/trigger/' . $this->flow_id ); $request->set_url_params( array( 'flow_id' => $this->flow_id ) ); $request->set_param( 'flow_id', $this->flow_id ); $request->set_header( 'content-type', 'application/json' ); - foreach ( $headers as $key => $value ) { - $request->set_header( $key, $value ); + foreach ( $headers as $k => $v ) { + $request->set_header( $k, $v ); } $request->set_body( $body ); return $request; @@ -361,11 +412,6 @@ private function get_scheduling_config(): array { return $flow['scheduling_config'] ?? array(); } - private function update_scheduling_config( array $config ): void { - $db = new Flows(); - $db->update_flow( $this->flow_id, array( 'scheduling_config' => $config ) ); - } - private function assert_is_unauthorized( $response ): void { $this->assertInstanceOf( \WP_Error::class, $response ); $this->assertSame( 401, $response->get_error_data()['status'] ); @@ -376,10 +422,10 @@ private function assert_not_unauthorized( $response ): void { $this->assertNotSame( 401, $response->get_error_data()['status'] ?? null, - 'Expected request to authenticate, got: ' . $response->get_error_message() + 'Expected auth pass, got: ' . $response->get_error_message() ); } else { - $this->assertTrue( true ); // successful response + $this->assertTrue( true ); } } } diff --git a/tests/Unit/Api/WebhookVerifierTest.php b/tests/Unit/Api/WebhookVerifierTest.php new file mode 100644 index 000000000..7bea17878 --- /dev/null +++ b/tests/Unit/Api/WebhookVerifierTest.php @@ -0,0 +1,468 @@ +assertTrue( + $result->ok, + sprintf( '[%s] expected ok, got reason=%s detail=%s', $name, $result->reason, $result->detail ?? '' ) + ); + if ( null !== $timestamp ) { + $this->assertSame( $timestamp, $result->timestamp, "[{$name}] timestamp should be extracted" ); + } + } + + /** + * @dataProvider providerMatrix + */ + public function test_matrix_tampered_body_rejected( string $name, array $config, array $headers, string $body, int $now ): void { + if ( '' === $body || false === strpos( (string) $config['signed_template'], '{body}' ) ) { + $this->assertTrue( true ); // Template doesn't sign the body — wrong-secret test covers this. + return; + } + $result = WebhookVerifier::verify( $body . 'TAMPER', $headers, array(), array(), 'https://example.com/', $config, $now ); + $this->assertFalse( $result->ok, "[{$name}] tampered body should not verify" ); + } + + /** + * @dataProvider providerMatrix + */ + public function test_matrix_wrong_secret_rejected( string $name, array $config, array $headers, string $body, int $now ): void { + $config['secrets'] = array( array( 'id' => 'current', 'value' => 'wrong-secret-value' ) ); + $result = WebhookVerifier::verify( $body, $headers, array(), array(), 'https://example.com/', $config, $now ); + $this->assertFalse( $result->ok, "[{$name}] wrong secret should not verify" ); + } + + public function providerMatrix(): array { + $secret = self::SECRET; + $body = self::BODY; + $now = 1700000000; + $ts = 1700000000; + $cases = array(); + + // GitHub-style: sha256= prefixed header. + $cases['prefixed_hex'] = array( + 'prefixed_hex', + array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Signature-256', + 'extract' => array( 'kind' => 'prefix', 'key' => 'sha256=' ), + 'encoding' => 'hex', + ), + 'secrets' => array( array( 'id' => 'current', 'value' => $secret ) ), + ), + array( 'x-signature-256' => 'sha256=' . hash_hmac( 'sha256', $body, $secret ) ), + $body, + $now, + null, + ); + + // Shopify-style: base64 in a single header. + $cases['base64_header'] = array( + 'base64_header', + array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Hmac-Sha256', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'base64', + ), + 'secrets' => array( array( 'id' => 'current', 'value' => $secret ) ), + ), + array( 'x-hmac-sha256' => base64_encode( hash_hmac( 'sha256', $body, $secret, true ) ) ), + $body, + $now, + null, + ); + + // Linear-style: raw hex. + $cases['raw_hex'] = array( + 'raw_hex', + array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Webhook-Signature', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ), + 'secrets' => array( array( 'id' => 'current', 'value' => $secret ) ), + ), + array( 'x-webhook-signature' => hash_hmac( 'sha256', $body, $secret ) ), + $body, + $now, + null, + ); + + // Stripe-style: t=,v1= composite, signed "{ts}.{body}". + $stripe_sig = hash_hmac( 'sha256', $ts . '.' . $body, $secret ); + $cases['kv_timestamped'] = array( + 'kv_timestamped', + array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{timestamp}.{body}', + 'signature_source' => array( + 'header' => 'X-Composite-Signature', + 'extract' => array( 'kind' => 'kv_pairs', 'key' => 'v1', 'separator' => ',' ), + 'encoding' => 'hex', + ), + 'timestamp_source' => array( + 'header' => 'X-Composite-Signature', + 'extract' => array( 'kind' => 'kv_pairs', 'key' => 't', 'separator' => ',' ), + 'format' => 'unix', + ), + 'tolerance_seconds' => 300, + 'secrets' => array( array( 'id' => 'current', 'value' => $secret ) ), + ), + array( 'x-composite-signature' => "t={$ts},v1={$stripe_sig}" ), + $body, + $now, + $ts, + ); + + // Slack-style: v0= plus separate timestamp header, signed "v0:{ts}:{body}". + $slack_sig = hash_hmac( 'sha256', 'v0:' . $ts . ':' . $body, $secret ); + $cases['separate_timestamp'] = array( + 'separate_timestamp', + array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => 'v0:{timestamp}:{body}', + 'signature_source' => array( + 'header' => 'X-Signature', + 'extract' => array( 'kind' => 'prefix', 'key' => 'v0=' ), + 'encoding' => 'hex', + ), + 'timestamp_source' => array( + 'header' => 'X-Request-Timestamp', + 'extract' => array( 'kind' => 'raw' ), + 'format' => 'unix', + ), + 'tolerance_seconds' => 300, + 'secrets' => array( array( 'id' => 'current', 'value' => $secret ) ), + ), + array( + 'x-signature' => 'v0=' . $slack_sig, + 'x-request-timestamp' => (string) $ts, + ), + $body, + $now, + $ts, + ); + + // Svix/Standard-Webhooks style: space-separated v1, with id + timestamp. + $svix_id = 'msg_abc123'; + $svix_signed = $svix_id . '.' . $ts . '.' . $body; + $svix_sig = base64_encode( hash_hmac( 'sha256', $svix_signed, $secret, true ) ); + $cases['id_timestamped'] = array( + 'id_timestamped', + array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{id}.{timestamp}.{body}', + 'signature_source' => array( + 'header' => 'Webhook-Signature', + 'extract' => array( 'kind' => 'kv_pairs', 'key' => 'v1', 'separator' => ' ', 'pair_separator' => ',' ), + 'encoding' => 'base64', + ), + 'timestamp_source' => array( + 'header' => 'Webhook-Timestamp', + 'extract' => array( 'kind' => 'raw' ), + 'format' => 'unix', + ), + 'id_source' => array( 'header' => 'Webhook-Id' ), + 'tolerance_seconds' => 300, + 'secrets' => array( array( 'id' => 'current', 'value' => $secret ) ), + ), + array( + 'webhook-id' => $svix_id, + 'webhook-timestamp' => (string) $ts, + 'webhook-signature' => "v1,{$svix_sig}", + ), + $body, + $now, + $ts, + ); + + return $cases; + } + + /** + * Twilio-style URL + param test — signed string excludes the body, so + * it needs its own entry that passes a specific URL + post params to + * `verify()`. The provider matrix uses a fixed URL, so this test sits + * alongside. + */ + public function test_url_and_param_placeholders(): void { + $secret = self::SECRET; + $url = 'https://example.com/twilio'; + $from = '+15005550006'; + $to = '+15005550001'; + $signed = $url . $from . $to; + $twilio_sig = base64_encode( hash_hmac( 'sha1', $signed, $secret, true ) ); + + $config = array( + 'mode' => 'hmac', + 'algo' => 'sha1', + 'signed_template' => '{url}{param:From}{param:To}', + 'signature_source' => array( + 'header' => 'X-Twilio-Signature', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'base64', + ), + 'secrets' => array( array( 'id' => 'current', 'value' => $secret ) ), + ); + + $result = WebhookVerifier::verify( + '', + array( 'x-twilio-signature' => $twilio_sig ), + array(), + array( 'From' => $from, 'To' => $to ), + $url, + $config + ); + + $this->assertTrue( $result->ok, $result->reason . ' ' . ( $result->detail ?? '' ) ); + } + + /* -------- Security / rotation edges -------- */ + + public function test_stale_timestamp_rejected(): void { + $ts = 1700000000; + $now = $ts + 3600; + $cfg = $this->stripe_like_config( $ts ); + $res = WebhookVerifier::verify( self::BODY, array( 'x-composite-signature' => "t={$ts},v1=" . hash_hmac( 'sha256', $ts . '.' . self::BODY, self::SECRET ) ), array(), array(), 'https://example.com/', $cfg, $now ); + $this->assertFalse( $res->ok ); + $this->assertSame( WebhookVerificationResult::STALE_TIMESTAMP, $res->reason ); + $this->assertSame( 3600, $res->skew_seconds ); + } + + public function test_fresh_timestamp_accepted(): void { + $ts = 1700000000; + $now = $ts + 60; + $cfg = $this->stripe_like_config( $ts ); + $res = WebhookVerifier::verify( self::BODY, array( 'x-composite-signature' => "t={$ts},v1=" . hash_hmac( 'sha256', $ts . '.' . self::BODY, self::SECRET ) ), array(), array(), 'https://example.com/', $cfg, $now ); + $this->assertTrue( $res->ok ); + } + + public function test_multi_secret_rotation_previous_verifies(): void { + $now = 1700000000; + $cfg = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Sig', + 'extract' => array( 'kind' => 'prefix', 'key' => 'sha256=' ), + 'encoding' => 'hex', + ), + 'secrets' => array( + array( 'id' => 'current', 'value' => 'NEW' ), + array( 'id' => 'previous', 'value' => 'OLD', 'expires_at' => $now + 3600 ), + ), + ); + $sig = 'sha256=' . hash_hmac( 'sha256', self::BODY, 'OLD' ); + $res = WebhookVerifier::verify( self::BODY, array( 'x-sig' => $sig ), array(), array(), '', $cfg, $now ); + $this->assertTrue( $res->ok ); + $this->assertSame( 'previous', $res->secret_id ); + } + + public function test_expired_previous_secret_skipped(): void { + $now = 1700000000; + $cfg = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Sig', + 'extract' => array( 'kind' => 'prefix', 'key' => 'sha256=' ), + 'encoding' => 'hex', + ), + 'secrets' => array( + array( 'id' => 'current', 'value' => 'NEW' ), + array( 'id' => 'previous', 'value' => 'OLD', 'expires_at' => $now - 1 ), + ), + ); + $sig = 'sha256=' . hash_hmac( 'sha256', self::BODY, 'OLD' ); + $res = WebhookVerifier::verify( self::BODY, array( 'x-sig' => $sig ), array(), array(), '', $cfg, $now ); + $this->assertFalse( $res->ok ); + $this->assertSame( WebhookVerificationResult::BAD_SIGNATURE, $res->reason ); + } + + public function test_missing_header_reported(): void { + $cfg = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Absent', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ), + 'secrets' => array( array( 'id' => 'current', 'value' => self::SECRET ) ), + ); + $res = WebhookVerifier::verify( self::BODY, array(), array(), array(), '', $cfg ); + $this->assertFalse( $res->ok ); + $this->assertSame( WebhookVerificationResult::MISSING_HEADER, $res->reason ); + } + + public function test_payload_too_large(): void { + $cfg = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Sig', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ), + 'secrets' => array( array( 'id' => 'current', 'value' => self::SECRET ) ), + 'max_body_bytes' => 16, + ); + $big = str_repeat( 'a', 128 ); + $res = WebhookVerifier::verify( $big, array( 'x-sig' => hash_hmac( 'sha256', $big, self::SECRET ) ), array(), array(), '', $cfg ); + $this->assertFalse( $res->ok ); + $this->assertSame( WebhookVerificationResult::PAYLOAD_TOO_LARGE, $res->reason ); + } + + public function test_no_active_secrets_fails_fast(): void { + $now = 1700000000; + $cfg = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{body}', + 'signature_source' => array( + 'header' => 'X-Sig', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ), + 'secrets' => array( + array( 'id' => 'expired', 'value' => 'x', 'expires_at' => $now - 10 ), + ), + ); + $res = WebhookVerifier::verify( self::BODY, array( 'x-sig' => 'abc' ), array(), array(), '', $cfg, $now ); + $this->assertFalse( $res->ok ); + $this->assertSame( WebhookVerificationResult::NO_ACTIVE_SECRET, $res->reason ); + } + + public function test_malformed_template_rejected(): void { + $cfg = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{unknown_placeholder}', + 'signature_source' => array( + 'header' => 'X-Sig', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ), + 'secrets' => array( array( 'id' => 'current', 'value' => self::SECRET ) ), + ); + $res = WebhookVerifier::verify( self::BODY, array( 'x-sig' => str_repeat( 'a', 64 ) ), array(), array(), '', $cfg ); + $this->assertFalse( $res->ok ); + $this->assertSame( WebhookVerificationResult::MALFORMED_TEMPLATE, $res->reason ); + } + + public function test_unknown_mode_goes_to_filter_registry(): void { + $res = WebhookVerifier::verify( '', array(), array(), array(), '', array( 'mode' => 'ed25519' ) ); + $this->assertFalse( $res->ok ); + $this->assertSame( WebhookVerificationResult::UNKNOWN_MODE, $res->reason ); + } + + public function test_unix_ms_timestamp_parsed(): void { + $ts_sec = 1700000000; + $now = $ts_sec; + $cfg = array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{timestamp}.{body}', + 'signature_source' => array( + 'header' => 'X-Sig', + 'extract' => array( 'kind' => 'raw' ), + 'encoding' => 'hex', + ), + 'timestamp_source' => array( + 'header' => 'X-Ts', + 'extract' => array( 'kind' => 'raw' ), + 'format' => 'unix_ms', + ), + 'tolerance_seconds' => 5, + 'secrets' => array( array( 'id' => 'current', 'value' => self::SECRET ) ), + ); + $res = WebhookVerifier::verify( + self::BODY, + array( + 'x-sig' => hash_hmac( 'sha256', $ts_sec . '.' . self::BODY, self::SECRET ), + 'x-ts' => (string) ( $ts_sec * 1000 ), + ), + array(), + array(), + '', + $cfg, + $now + ); + $this->assertTrue( $res->ok ); + $this->assertSame( $ts_sec, $res->timestamp ); + } + + private function stripe_like_config( int $ts ): array { + return array( + 'mode' => 'hmac', + 'algo' => 'sha256', + 'signed_template' => '{timestamp}.{body}', + 'signature_source' => array( + 'header' => 'X-Composite-Signature', + 'extract' => array( 'kind' => 'kv_pairs', 'key' => 'v1', 'separator' => ',' ), + 'encoding' => 'hex', + ), + 'timestamp_source' => array( + 'header' => 'X-Composite-Signature', + 'extract' => array( 'kind' => 'kv_pairs', 'key' => 't', 'separator' => ',' ), + 'format' => 'unix', + ), + 'tolerance_seconds' => 300, + 'secrets' => array( array( 'id' => 'current', 'value' => self::SECRET ) ), + ); + } + } +}