# Payroll Token Encryption Pattern

## Overview

Per-tenant Everee API tokens and webhook secrets are encrypted at rest using
`BuyerKiosk\Security\Encryption` (OpenSSL AES-256-CBC, random-IV-prepended-base64).
The master key is sourced from `EVEREE_ENCRYPTION_KEY` in the environment — a
separate key from `QB_ENCRYPTION_KEY` so the two integrations can rotate
independently (ADR-1).

This pattern mirrors the existing QuickBooks token storage approach (ICO-2).

---

## Components

| Component | Path | Purpose |
|-----------|------|---------|
| `Encryption` | `src/BuyerKiosk/Security/Encryption.php` | OpenSSL primitive (PRESERVE — shared with QB) |
| `EvereeTokenStorage` | `src/BuyerKiosk/Payroll/Services/EvereeTokenStorage.php` | Only path to plaintext token |
| `PayrollTenant` | `src/BuyerKiosk/Payroll/Models/PayrollTenant.php` | Value object; holds encrypted bytes privately |
| `config/everee-encryption.php` | `userfrosting/config/everee-encryption.php` | Loads `EVEREE_ENCRYPTION_KEY` from `$_ENV` |
| `EvereeEncryptionRequiredException` | `src/BuyerKiosk/Payroll/Exceptions/EvereeEncryptionRequiredException.php` | Thrown on write without configured key |
| `TokenDecryptionException` | `src/BuyerKiosk/Payroll/Exceptions/TokenDecryptionException.php` | Thrown on any decrypt failure |

---

## Fail-Closed Posture

**Writes** (`encryptTokenForStorage`, `encryptWebhookSecretForStorage`) throw
`EvereeEncryptionRequiredException` if `EVEREE_ENCRYPTION_KEY` is missing or blank.
This means a misconfigured deploy fails loudly at provisioning time — no plaintext
token ever reaches the database.

**Reads** (`decryptTokenForUse`, `decryptWebhookSecretForUse`, `decryptWebhookPriorSecretForUse`)
throw `TokenDecryptionException` on ANY failure (null cipher, missing key, OpenSSL
returning false). There is NO fallback path that returns partial or garbage plaintext.
This is intentional: a decrypt failure is a signal that re-provisioning or key
rotation is required.

---

## Model-Level Serialization Safety

`PayrollTenant` carries the three encrypted fields as `private readonly`:
- `evereeApiTokenEncrypted`
- `webhookSecretEncrypted`
- `webhookPriorSecretEncrypted`

These are **never** returned by `jsonSerialize()`, `__debugInfo()`, or
`toAuditSnapshot()`. PHP's `json_encode()`, `var_dump()`, and `print_r()` on a
`PayrollTenant` instance will never reveal ciphertext.

`__debugInfo()` returns presence-flags instead:
```php
'_apiTokenPresent'      => bool,
'_webhookSecretPresent' => bool,
'_priorSecretPresent'   => bool,
```

Access to the raw encrypted bytes is via named methods:
- `getEncryptedApiTokenForServiceUse()` — for `EvereeTokenStorage` and repositories only
- `getEncryptedWebhookSecretForServiceUse()`
- `getEncryptedWebhookPriorSecretForServiceUse()`

---

## Audit Snapshot Contract

`PayrollTenant::toAuditSnapshot()` returns a flat array of business fields only.
It is the canonical sanitized snapshot for `PayrollAuditService::logTenantProvisioned()`:

```php
$audit->logTenantProvisioned(
    tenantId:       $tenant->getId(),
    tenantSnapshot: $tenant->toAuditSnapshot(),  // NO encrypted fields
    actorUserId:    $actorUserId
);
```

---

## Key Rotation (documented — not yet implemented)

To rotate the master key:
1. Generate a new key: `php -r "echo bin2hex(random_bytes(32));"`
2. Re-encrypt all rows in `payrollTenants`:
   - Read `evereeApiTokenEncrypted`, `webhookSecretEncrypted`,
     `webhookPriorSecretEncrypted` for each row.
   - Decrypt with the OLD key via `Encryption::decrypt()`.
   - Re-encrypt with the NEW key via `Encryption::encrypt()`.
   - UPDATE the row.
3. Update `EVEREE_ENCRYPTION_KEY` in the environment.
4. Deploy and restart workers.

**This is intentionally NOT automated in Phase 0/1a.** A rotation runbook will
be authored in Phase 1b.

---

## CI Token-Leak Check

`userfrosting/bin/payroll/check-token-leaks.php` scans `src/BuyerKiosk/Payroll/`
for lines where sensitive variable names (e.g. `$token`, `$apiToken`,
`$webhookSecret`, `$plaintext`) appear in dangerous contexts (e.g. `error_log(`,
`var_dump(`, `throw new ...Exception(`, logger calls).

```bash
# Run manually
php userfrosting/bin/payroll/check-token-leaks.php

# Run against a specific directory
php userfrosting/bin/payroll/check-token-leaks.php --dir src/BuyerKiosk/Payroll
```

Exit code 0 = clean. Exit code 1 = findings (review each one).

### Extending the check

To add a new sensitive variable pattern, add it to `SENSITIVE_VAR_PATTERNS`
at the top of the scanner script.

To add a new dangerous context (e.g. a new logger class), add it to
`DANGEROUS_CONTEXT_PATTERNS`.

To suppress a specific line (e.g. you are intentionally logging the token's
presence flag, not its value), add `// PAYROLL_TOKEN_NOCHECK` to that line:
```php
error_log('Token present: ' . ($token !== null ? 'yes' : 'no')); // PAYROLL_TOKEN_NOCHECK
```

### Known limitations

- Line-by-line grep: cannot detect leaks inside multi-line string concatenations
  or heredocs.
- Conservative false positives (e.g. `$tokenCount` may match `token`).
  Suppress with `// PAYROLL_TOKEN_NOCHECK`.
- A PHPStan custom rule (Phase 1c stretch) can close the gap for AST-level analysis.

---

## Environment Variables

| Variable | Purpose | Required |
|----------|---------|----------|
| `EVEREE_ENCRYPTION_KEY` | 32-byte hex master key for token + webhook secret encryption | YES (all environments) |
| `QB_ENCRYPTION_KEY` | QuickBooks master key — separate from Everee (ADR-1) | For QB integration |

Generate a key: `php -r "echo bin2hex(random_bytes(32));"`

---

## References

- `docs/specs/050-everee-payroll-foundations/solution-design.md` §ADR-1, §Example 4
- `src/BuyerKiosk/QuickBooks/QuickBooksService.php` (pattern template)
- `userfrosting/config/qb-encryption.php` (shape template)
- PRD Feature 4 Acceptance Criteria
- CON-13: "Plaintext Everee API tokens never appear in logs, exceptions, or JSON dumps"
