# Licensing hardening — recoverable key storage + dead update verification

- **Status:** ACTIVE
- **Worktree:** `.worktrees/licensing-hardening`, branch `wt/licensing-hardening` off `origin/master` `7878d56ba`
- **Task IDs:** LH-01 encryption, LH-02 update verification, LH-03 sibling transport fixes, LH-04 gates/baselines

## Outcome

License keys stored under authenticated encryption with no recoverable fallback, update packages verified before extraction, and license credentials never carried in a URL query string.

## Source request

Parent dispatch: fix two defects confirmed on `origin/master`, then harvest six audit-approved files from
`refs/wip-snapshot/current-20260811/wt-ask-gpt-cap02`.

- **Defect 1** — `ActivationManager::getEncryptionKey()` returns the literal `ipz-default-encryption-key-123`
  when `AUTH_KEY` is undefined, and `encrypt()` falls back to bare `base64_encode()`. Stored license keys are
  recoverable by anyone who can read the database.
- **Defect 2** — `UpdateChecker::verifyPackageSignature()` calls `LicenseClient::request()`, which is `private`
  (guaranteed fatal), has no callers, and is recorded unused in `phpstan-baseline.neon`. Zero update-package
  verification ships.

## Acceptance criteria

1. AES-256-GCM storage with a `v2:` prefix; no recoverable fallback on any path; activation fails closed
   (`secure_storage_unavailable`) when secure storage is unavailable.
2. One-shot legacy `AES-256-CBC` migration that reads existing installs and never overwrites a legacy value
   with a failed encryption.
3. Package verification wired to `upgrader_pre_download`, https-only, rejected downloads deleted through
   `wp_delete_file()`.
4. License credentials leave the URL: `/updates/check` and `/license/status` become POST.
5. No existing check, assertion, or baseline entry weakened. Obsolete baseline entries regenerated, not
   hand-edited.
6. All `tools/factory-gate.sh` gates green with every warning and notice resolved.

## Constraints

- Out of scope: cancellation/idempotency work (needs a schema migration and live backend facts).
- HARD REJECT: `includes/Translation/JobSender.php`, `tests/unit/Translation/JobSenderRetryStandaloneTest.php`,
  anything from branch `cap02-active-client` wholesale, anything from `wt/land-cap02` (line-collapsed).
- `.worktrees/ask-gpt-cap02` and `.worktrees/land-cap02` are read-only evidence.
- No push, merge, deploy, or ref/worktree deletion. Parent controls landing.

## Execution steps

1. Port `ActivationManager` (crypto + fail-closed activation), `LicenseClient` (safe transport + response
   normalization + `verify_update_package`), `LicenseValidator` (standardized error keys, keep
   `performAdditionalChecks`), `UpdateChecker` (`verify_update_download`).
2. Sibling fixes not covered by the harvest: `getLicenseInfo()` GET→POST, `testConnection()` →
   `wp_safe_remote_get`.
3. Land the two harvested standalone tests plus a new `AUTH_KEY`-absent test that covers defect 1's real
   trigger (both harvested tests define `AUTH_KEY`, so neither reaches the defective branch).
4. Update `SiteIdentityRegressionStandaloneTest` stubs/expectations to the new transport contract without
   dropping any assertion.
5. Wire `tests/unit/Licensing` into the gate — the existing licensing test is referenced nowhere and is
   therefore ungated today.
6. Regenerate phpstan/phpcs baselines; confirm the delta is removals only.

## Split into landable slices

Branch `wt/licensing-hardening-split`, rebased onto `origin/master` `92973ea5c`. `wt/licensing-hardening`
`1afebda61` and `recovery/licensing-hardening-1afebda6` are retained unchanged as recovery refs. One branch,
three sequential commits, because Slice B and Slice C both build on `LicenseClient::get_active_license_key()`
introduced in Slice A and all three touch `factory-gate.sh` and `phpstan-baseline.neon`; separate branches
would duplicate or conflict.

- **Slice A — land now.** AES-256-GCM `v2:` storage, one-shot legacy CBC migration, removal of the
  `ipz-default-encryption-key-123` fallback and the bare `base64_encode()` paths, fail-closed activation
  (`secure_storage_unavailable`), `wp_safe_remote_request`/`wp_safe_remote_get`, the `! is_array()` response
  guard, fail-closed handling of HTTP 200 carrying `success: false`, `checkForUpdates()` license gating and
  envelope unwrapping, the `LicenseValidator` error-key fix, and their regression tests. Safe against the
  live backend exactly as it stands today.
- **Slice B — hold.** `getLicenseInfo()` `GET` → `POST` on `/license/status`, plus its
  `SiteIdentityRegressionStandaloneTest` expectation. Unblocks when the backend serves
  `POST /v1/international/license/status`.
- **Slice C — hold.** `upgrader_pre_download` wiring, fail-closed `verify_update_download`,
  `LicenseClient::verify_update_package()`, `UpdatePackageVerificationStandaloneTest`, its `factory-gate.sh`
  entry, and the two `UpdateChecker.php` phpstan-baseline removals. Unblocks when the backend serves
  `POST /v1/international/updates/verify`.

## Probe evidence (`https://api.press.zone/v1/international`, 2026-08-11)

| Route | GET | POST |
|---|---|---|
| `/license/status` | 200 | 404 |
| `/license/activate` | 404 | 400 (served, validates body) |
| `/license/deactivate` | 404 | 400 (served, validates body) |
| `/license/validate` | 404 | 400 (served, validates body) |
| `/updates/check` | 404 | 404 |
| `/updates/verify` | 404 | 404 |

Response envelope confirmed live: `{"success":bool,"data":{...}|"error":{"code","message"},"timestamp"}`.
Slice A's strict `success === true` gate and `failure_response()` normalization match that contract.

`/updates/check` is already 404 on master, so update *checking* is non-functional today independently of this
lane. Any non-2xx from `/updates/check` makes `request()` return `success: false`, so `checkForUpdates()`
returns `null` on master and on Slice A alike, for licensed and unlicensed sites. Because Slice A leaves
`UpdateChecker.php` byte-identical to master, `checkForUpdates()` is its only entry point into update-notice
logic — Slice A changes nothing about whether notices appear.

Slice A independence from `/updates/verify`, machine-verified: the Slice A tree contains zero occurrences of
`verify_update_package` and `upgrader_pre_download`, and exactly one occurrence each of `/updates/verify` and
`verifyPackageSignature` — both inside master's own private, uncalled `UpdateChecker::verifyPackageSignature()`.
PHPStan asserts that unreachability rather than it being claimed: the regenerated baseline retains
`Method ...UpdateChecker::verifyPackageSignature() is unused.` and
`Call to private method request() of class ...LicenseClient.`

Holding Slice B leaves `/license/status` credentials in the query string, which is master's existing behavior;
no check is weakened, the fix is deferred with the route that would serve it.

## Landing preconditions (parent-owned)

- Slice C only: `POST /v1/international/updates/verify` must be live. `verify_update_download` is fail-closed —
  if the endpoint is absent, every plugin update is blocked rather than merely unverified.
- Slice B only: `POST /v1/international/license/status` must accept a JSON body. Probed 404 on 2026-08-11.
- Slice A carries `/updates/check` `GET` → `POST`. Not a precondition: the route is 404 on both verbs, so the
  call fails closed to `null` either way, identically to master.
- Intentional behavior change: unlicensed sites no longer receive update notices (`checkForUpdates()` returns
  `null` with no license key, where master performed an anonymous check).
- `wp_safe_remote_*` sets `reject_unsafe_urls`, which rejects private, loopback, and link-local hosts. Any
  dev or staging configuration pointing `IPZ_API_BASE_URL` at a LAN or localhost backend now fails closed on
  every licensing call, including `testConnection()`. Production (`https://api.press.zone`) is unaffected.
- Methods added by this change use snake_case (`verify_update_package`, `get_active_license_key`,
  `failure_response`, `decrypt_legacy`, `verify_update_download`) rather than the audited artifact's
  camelCase. Behavior is unchanged; the rename is what let the fail-closed phpcs gate pass without growing
  `phpcs-baseline.json`.

## Current receipt

All four licensing files ported, both sibling transport fixes applied, three regression tests landed and wired
into `factory-gate.sh test` through the per-process runner.

RED evidence on pristine master:

- Defect 1: `getEncryptionKey()` returned `ipz-default-encryption-key-123`, and a probe recovered the full
  plaintext license `ABCD-EFGH-IJKL-MNOP` from the stored option using only that public constant.
- Defect 2: `verifyPackageSignature()` raised `Error: Call to private method LicenseClient::request()`, and
  no `upgrader_pre_download` hook existed.
- Harvested tests re-run against master with a `wp_remote_request` alias (so the stub-coverage mismatch could
  not stand in for the real failure): master treated HTTP 200 + `success:false` as success, and stored a
  non-authenticated license value.

Gates: php-syntax, test (28 PHPUnit + 3 procedural), phpstan (`No errors`), phpcs baseline check (exit 0),
admin-lint, admin build (run directly; see pre-existing findings). phpstan baseline regenerated with a
removals-only delta (297→295). `phpcs-baseline.json` is unmodified — every new line was written to the
ruleset instead of growing the suppression.

Pre-existing findings, reported not fixed (out of scope, fail identically on `7878d56ba`):

- `SiteIdentityRegressionStandaloneTest` fails on PHP 8.4 (`parse_url` now parses `:0` as port 0, so a
  malformed Site Address is accepted). It is referenced by no runner and was already ungated.
- `composer phpcs` exits 2 on pristine master; the baseline check is the real gate.
- `factory-gate.sh build` fails with `webpack: not found` because `npm --prefix` omits the package's
  `node_modules/.bin` from PATH. The build succeeds when run from `admin/`.

## Split receipt

Slice A gates on `92973ea5c` (all green, no warnings or notices): php-syntax clean; `factory-gate.sh test`
28 PHPUnit + 2 procedural licensing regressions, all passing; `composer phpstan` `No errors` after
regenerating the baseline; `php tools/phpcs-baseline.php check` on all four licensing files exit 0;
`npm run lint:js` and `npm run build` clean from `admin/`. `phpstan-baseline.neon` and `phpcs-baseline.json`
are both byte-identical to `92973ea5c` — zero growth, and `UpdateChecker.php` is byte-identical to master.

`npm --prefix` omits the package's `node_modules/.bin` from PATH, so `factory-gate.sh admin-lint` and
`factory-gate.sh build` fail with `eslint: not found` / `webpack: not found`. Pre-existing gate-script defect,
unrelated to this lane; both succeed when run from `admin/`.

## Next executable action

Parent-controlled landing of Slice A. Slice B and Slice C stay on the branch, unlanded, until their named
backend route is served; re-probe before landing either.
