# Server-Side Response Signing Implementation

This document describes how to implement response signing on the server side (e.g., in the WooCommerce Licensed Product plugin) to work with the `SecureLicenseClient`.

## Overview

The security model works as follows:

1. Server generates a unique signature for each response using HMAC-SHA256
2. Signature includes a timestamp to prevent replay attacks
3. Each license key has a unique derived secret (not the master secret)
4. Client verifies the signature using their per-license secret
5. Invalid signatures cause the client to reject the response

This prevents attackers from:

- Faking valid license responses
- Replaying old responses
- Tampering with response data
- Using one customer's secret to verify another customer's responses

## Requirements

- PHP 8.3+
- A server secret stored securely (not in version control)

## Server Configuration

### 1. Store the Server Secret

Add a secret key to your WordPress configuration:

```php
// wp-config.php or secure configuration file
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
```

Generate a secure secret:

```bash
# Using OpenSSL
openssl rand -hex 32

# Or using PHP
php -r "echo bin2hex(random_bytes(32));"
```

**IMPORTANT:** Never commit this secret to version control!

## Implementation

### Key Derivation

Each license key gets a unique signing key derived from the server secret using RFC 5869 HKDF:

```php
/**
 * Derive a unique signing key for a license.
 *
 * Uses PHP's native hash_hkdf() function per RFC 5869.
 *
 * @param string $licenseKey The license key (used as "info" context)
 * @param string $serverSecret The server's master secret (used as IKM)
 * @return string The derived key (hex encoded, 64 characters)
 */
function derive_signing_key(string $licenseKey, string $serverSecret): string
{
    // HKDF key derivation per RFC 5869
    // IKM: server_secret, Length: 32 bytes, Info: license_key
    return bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey));
}
```

**Important:** This uses PHP's native `hash_hkdf()` function (available since PHP 7.1.2). The parameters are:

- **Algorithm:** sha256
- **IKM (Input Keying Material):** server_secret
- **Length:** 32 bytes (256 bits)
- **Info:** license_key (context-specific information)

### Response Signing

Sign every API response before sending:

```php
/**
 * Sign an API response.
 *
 * @param array $responseData The response body (before JSON encoding)
 * @param string $licenseKey The license key from the request
 * @param string $serverSecret The server's master secret
 * @return array Headers to add to the response
 */
function sign_response(array $responseData, string $licenseKey, string $serverSecret): array
{
    $timestamp = time();
    $signingKey = derive_signing_key($licenseKey, $serverSecret);

    // Recursively sort keys for consistent ordering (important for nested arrays!)
    $responseData = recursive_key_sort($responseData);

    // Build signature payload
    $jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    $payload = $timestamp . ':' . $jsonBody;

    // Generate HMAC signature
    $signature = hash_hmac('sha256', $payload, $signingKey);

    return [
        'X-License-Signature' => $signature,
        'X-License-Timestamp' => (string) $timestamp,
    ];
}

/**
 * Recursively sort array keys alphabetically.
 */
function recursive_key_sort(array $data): array
{
    ksort($data);
    foreach ($data as $key => $value) {
        if (is_array($value)) {
            $data[$key] = recursive_key_sort($value);
        }
    }
    return $data;
}
```

### WordPress REST API Integration

Example integration with WooCommerce REST API:

```php
/**
 * Add signature headers to license API responses.
 */
add_filter('rest_post_dispatch', function($response, $server, $request) {
    // Only sign license API responses
    if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) {
        return $response;
    }

    // Get the response data
    $data = $response->get_data();

    // Get the license key from the request
    $licenseKey = $request->get_param('license_key');

    if (empty($licenseKey) || !is_array($data)) {
        return $response;
    }

    // Sign the response
    $serverSecret = defined('WC_LICENSE_SERVER_SECRET')
        ? WC_LICENSE_SERVER_SECRET
        : '';

    if (empty($serverSecret)) {
        // Log warning: server secret not configured
        return $response;
    }

    $signatureHeaders = sign_response($data, $licenseKey, $serverSecret);

    // Add headers to response
    foreach ($signatureHeaders as $name => $value) {
        $response->header($name, $value);
    }

    return $response;
}, 10, 3);
```

### Complete WordPress Plugin Example

```php
<?php
/**
 * Plugin Name: WC Licensed Product Signature
 * Description: Adds response signing to WC Licensed Product API
 * Version: 1.0.0
 */

namespace WcLicensedProduct\Security;

class ResponseSigner
{
    private string $serverSecret;

    public function __construct()
    {
        $this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
            ? WC_LICENSE_SERVER_SECRET
            : '';
    }

    public function register(): void
    {
        add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
    }

    public function signResponse($response, $server, $request)
    {
        if (!$this->shouldSign($request)) {
            return $response;
        }

        $data = $response->get_data();
        $licenseKey = $request->get_param('license_key');

        if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) {
            return $response;
        }

        $headers = $this->createSignatureHeaders($data, $licenseKey);

        foreach ($headers as $name => $value) {
            $response->header($name, $value);
        }

        return $response;
    }

    private function shouldSign($request): bool
    {
        $route = $request->get_route();

        return str_starts_with($route, '/wc-licensed-product/v1/validate')
            || str_starts_with($route, '/wc-licensed-product/v1/status')
            || str_starts_with($route, '/wc-licensed-product/v1/activate');
    }

    private function createSignatureHeaders(array $data, string $licenseKey): array
    {
        $timestamp = time();
        $signingKey = $this->deriveKey($licenseKey);

        $data = $this->recursiveKeySort($data);
        $payload = $timestamp . ':' . json_encode(
            $data,
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );

        return [
            'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey),
            'X-License-Timestamp' => (string) $timestamp,
        ];
    }

    private function recursiveKeySort(array $data): array
    {
        ksort($data);
        foreach ($data as $key => $value) {
            if (is_array($value)) {
                $data[$key] = $this->recursiveKeySort($value);
            }
        }
        return $data;
    }

    private function deriveKey(string $licenseKey): string
    {
        // HKDF key derivation per RFC 5869
        return bin2hex(hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey));
    }
}

// Initialize
add_action('init', function() {
    (new ResponseSigner())->register();
});
```

## Response Format

### Headers

Every signed response includes:

| Header | Description | Example |
| -------- | ------------- | --------- |
| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3d4...` (64 chars) |
| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` |

### Signature Algorithm

```text
signature = HMAC-SHA256(
    key = derive_signing_key(license_key, server_secret),
    message = timestamp + ":" + canonical_json(response_body)
)
```

Where:

- `derive_signing_key` uses RFC 5869 HKDF: `hash_hkdf('sha256', server_secret, 32, license_key)`
- `canonical_json` recursively sorts keys alphabetically, no escaping of slashes/unicode
- Result is hex-encoded (64 characters)

## Testing

### Verify Signing Works

```php
// Test script
$serverSecret = 'test-secret-key-for-development-only';
$licenseKey = 'ABCD-1234-EFGH-5678';
$responseData = [
    'valid' => true,
    'license' => [
        'product_id' => 123,
        'expires_at' => '2027-01-21',
        'version_id' => null,
    ],
];

$headers = sign_response($responseData, $licenseKey, $serverSecret);

echo "X-License-Signature: " . $headers['X-License-Signature'] . "\n";
echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n";
```

### Test with Client

```php
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Symfony\Component\HttpClient\HttpClient;

$client = new SecureLicenseClient(
    httpClient: HttpClient::create(),
    baseUrl: 'https://your-site.com',
    serverSecret: 'same-secret-as-server',
);

try {
    $info = $client->validate('ABCD-1234-EFGH-5678', 'example.com');
    echo "License valid! Product ID: " . $info->productId;
} catch (SignatureException $e) {
    echo "Signature verification failed - possible tampering!";
}
```

## Security Considerations

### Timestamp Tolerance

The client allows a 5-minute window for timestamp verification. This:

- Prevents replay attacks (old responses rejected)
- Allows for reasonable clock skew between server and client

Adjust if needed:

```php
// Client-side: custom tolerance
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
```

### Per-License Secrets

Each customer receives a unique secret derived from their license key. This means:

- Customers only know their own secret, not the master server secret
- If one customer's secret is leaked, other customers are not affected
- The server uses HKDF-like derivation to create unique secrets

#### How Customers Get Their Secret

Customers can find their per-license verification secret in their account:

1. Log in to the store
2. Go to My Account > Licenses
3. Click "API Verification Secret" under any license
4. Copy the 64-character hex string

This secret is automatically derived from the customer's license key and the server's master secret.

#### Using the Customer Secret

```php
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Symfony\Component\HttpClient\HttpClient;

// Customer uses their per-license secret (from account page)
$client = new SecureLicenseClient(
    httpClient: HttpClient::create(),
    baseUrl: 'https://shop.example.com',
    serverSecret: 'customer-secret-from-account-page', // 64 hex chars
);

$info = $client->validate('XXXX-XXXX-XXXX-XXXX', 'example.com');
```

### Secret Key Rotation

To rotate the server secret:

1. Deploy new secret to server
2. All per-license secrets change automatically (they're derived)
3. Customers must copy their new secret from their account page
4. Old signatures become invalid immediately

For zero-downtime rotation, implement versioned secrets:

```php
// Server supports both old and new secrets during transition
$secrets = [
    'v2' => 'new-secret',
    'v1' => 'old-secret',
];

// Add version to signature header
$response->header('X-License-Signature-Version', 'v2');
```

### Error Responses

Sign error responses too! Otherwise attackers could craft fake error messages:

```php
// Sign both success and error responses
$errorData = [
    'valid' => false,
    'error' => 'license_expired',
    'message' => 'This license has expired.',
];

$headers = sign_response($errorData, $licenseKey, $serverSecret);
```

## Troubleshooting

### "Response is not signed by the server"

- Server not configured with `WC_LICENSE_SERVER_SECRET`
- Filter not registered (check plugin activation)
- Route mismatch (check `shouldSign()` paths)

### "Response signature verification failed"

- Different secrets on server/client
- Clock skew > 5 minutes
- Response body modified after signing (e.g., by caching plugin)
- JSON encoding differences (check `ksort` and flags)

### Debugging

Enable detailed logging:

```php
// Server-side
error_log('Signing response for: ' . $licenseKey);
error_log('Timestamp: ' . $timestamp);
error_log('Payload: ' . $payload);
error_log('Signature: ' . $signature);

// Client-side: use a PSR-3 logger
$client = new SecureLicenseClient(
    // ...
    logger: new YourDebugLogger(),
);
```
