# Authentication

Every request must carry HMAC-SHA256 authentication headers. The signed
payload is canonicalised so a captured signature cannot be replayed
against a different endpoint, body or window.

| Header | Description |
|--------|-------------|
| `X-WHost-Key` | API key id (configurable in `/admin/api-keys`). |
| `X-WHost-Timestamp` | Current Unix timestamp (seconds). |
| `X-WHost-Nonce` | 16–256 characters, unique per request (32 hex chars recommended); a replayed nonce is refused. |
| `X-WHost-Signature` | `HMAC-SHA256(api_secret, "METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY")` hex digest. |

The agent rejects requests:

- with a timestamp older than **300 seconds**,
- whose nonce was already seen inside the replay window (twice the 300 s timestamp tolerance plus 60 s),
- whose signature, recomputed server-side, doesn't match,
- whose path drifts from the signed one after `posixpath.normpath` and
  matrix-param stripping (so trailing `;foo=bar` cannot bypass the path
  binding).

### Account-bound (reseller) keys

A key minted from the client panel by a reseller account (Settings → API Access) signs requests exactly like a server key, but is served **as that reseller**: it reaches only `/api/v1/client/*` and acts there with the reseller's ownership checks and ACL plan. What such a key gets back:

| Request | Answer |
|---|---|
| Any path outside `/api/v1/client/` | `403 SCOPE_DENIED` |
| `/api/v1/client/api-keys*`, `/api/v1/client/profile*` (credential and profile management) | `403 HMAC_FORBIDDEN_FOR_CREDENTIAL_MUTATION` — browser session only |
| A sub-account the reseller does not own | `404 ACCOUNT_NOT_FOUND` |
| Reseller's ACL plan without `api_access`, or the account no longer a reseller | `403 PERMISSION_DENIED` |
| Owner account suspended, terminated or deleted; key revoked | `401 AUTH_FAILED` |

The key's scope is fixed (`client`) and cannot be widened; a reseller holds at most 10 keys. Management endpoints for the reseller's own keys: `GET/POST /api/v1/client/api-keys`, `PUT /api/v1/client/api-keys/{key_id}`, `POST /api/v1/client/api-keys/{key_id}/revoke`, `DELETE /api/v1/client/api-keys/{key_id}`, `GET /api/v1/client/api-keys/logs` — session cookie only. The administrator sees every key under `GET /api/v1/system/api-keys`; the `owner` field names the reseller (`null` for server-wide keys).

### Canonical signing payload (v2)

```
METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY
```

`PATH` is the agent-visible path (the part after the host, including
the `/api/v1/...` prefix). `BODY` is the **raw HTTP body bytes** decoded
as UTF-8 with `surrogateescape` (binary uploads are signable without
re-encoding).

### Reference Python signer

```python
import hashlib, hmac, json, secrets, time
from urllib.request import Request, urlopen
import ssl

API_KEY    = "your-api-key"
API_SECRET = "your-api-secret"
BASE_URL   = "https://panel.example.com"

def sign(method: str, path: str, body: bytes = b"") -> dict[str, str]:
    ts    = str(int(time.time()))
    nonce = secrets.token_hex(16)
    body_str = body.decode("utf-8", errors="surrogateescape")
    payload  = f"{method}\n{path}\n{ts}\n{nonce}\n{body_str}"
    sig = hmac.new(
        API_SECRET.encode("utf-8"),
        payload.encode("utf-8", errors="surrogateescape"),
        hashlib.sha256,
    ).hexdigest()
    return {
        "X-WHost-Key":       API_KEY,
        "X-WHost-Timestamp": ts,
        "X-WHost-Nonce":     nonce,
        "X-WHost-Signature": sig,
        "Content-Type":      "application/json",
    }

body = json.dumps({"username": "alice"}).encode("utf-8")
headers = sign("POST", "/api/v1/accounts", body)
req = Request(f"{BASE_URL}/api/v1/accounts", data=body,
              headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE  # remove in production
print(urlopen(req, context=ctx).read())
```

Signers in other languages live next to this file:

- PHP — [`docs/developer/examples/hmac-example.php`](examples/hmac-example.php)
- JavaScript / Node.js — [`docs/developer/examples/hmac-example.js`](examples/hmac-example.js)
- Go — [`docs/developer/examples/hmac-example.go`](examples/hmac-example.go)
- Ruby — [`docs/developer/examples/hmac-example.rb`](examples/hmac-example.rb)
- Bash / POSIX sh — [`docs/developer/examples/hmac-example.sh`](examples/hmac-example.sh)

### Admin session cookie (browser-only)

The `/admin/*` panel and the `/client/*` panel mint an `httpOnly`
session cookie via `POST /api/v1/auth/login` (then `/api/v1/auth/login/2fa`
when 2FA is enabled). SDKs and partner integrations should **always**
use HMAC instead — sessions are scoped to a browser context and rotate
on every privilege change.

---
