# Security Rules — My Book Showroom

> **A DMRS Tech Product** | US English throughout
> These rules are non-negotiable. Violations are treated as critical bugs.
> Read before writing any code that touches credentials, licenses, or user data.
> Last updated: Sprint 14 — S14-1 through S14-9 (April 25, 2026).

---

## 1. The Cardinal Rules

### Rule 1 — Never log secrets

The following values must NEVER appear in any log file, debug output, `error_log()` call,
`var_dump()`, database record (other than their designated encrypted/hashed columns), or
HTTP response:

- Raw license keys (plaintext `MBS-PRO-XXXX-XXXX-XXXX`)
- HMAC signatures or secrets
- The per-activation `api_secret` (stored encrypted as `mbs_api_secret_encrypted`)
- Amazon PA-API Access Key or Secret Key
- Stripe secret key or webhook secret
- PayPal client secret
- WordPress AUTH_KEY or any other WP secret key
- AES encryption keys or IVs
- Any value from `app/config.php` on the server
- Customer email addresses or transaction IDs from payment webhook events

**N-CODE-01 (Remediation Sprint F):** `MBS_Stripe_Adapter` and `MBS_PayPal_Adapter`
previously logged customer email addresses and transaction IDs via `error_log()` in their
provision-failure paths. These calls have been removed. Provision failures now fire
`do_action( 'mbs_provision_failed', $processor, $data, $code, $detail )` so failures can
be handled (e.g. stored as an admin-notice transient) without writing customer PII to server
logs. `MBS_Tamper_Detector` manifest failures now store a sanitized message in the
`mbs_manifest_last_error` wp_option instead of calling `error_log()`.

### Rule 2 — Never store secrets in plaintext

License keys stored in `wp_options` are AES-256-CBC encrypted using the WordPress AUTH_KEY.
PA-API credentials are AES-256-CBC encrypted using the same method. License keys on the
server are Argon2id hashed. Per-activation `api_secret` values on the server are
AES-256-CBC encrypted using the Laravel `APP_KEY` (`Crypt::encryptString()`). No secret is
ever stored raw.

**`mbs_license_data` option removed (Remediation Sprint A):** A secondary `mbs_license_data`
wp_option previously stored a JSON blob containing the raw plaintext license key for display
purposes. This option has been removed. `MBS_Tab_License_Render::render()` reads from
`mbs_license_key_encrypted` and decrypts inline, which was always the correct pattern. A
migration in `MBS_DB_Installer::maybe_migrate()` deletes the option on upgrade.

Payment processor signing secrets (`mbs_setting_payment_stripe_webhook_secret_encrypted`,
`mbs_setting_payment_paypal_webhook_secret_encrypted`) are excluded from all settings exports
via `EXCLUDED_KEYS` in `MBS_Settings_IO_Keys`. Even though they are stored encrypted, they
must never appear in a shareable export file. The same exclusion applies to `mbs_tamper_flag`
and `mbs_api_nonce` — see Rule 7 below.

### Rule 3 — Always use `mbs_is_pro()` / `mbs_is_pub()` for license checks

Never check `get_option('mbs_license_tier')` directly for tier comparisons. Both
`mbs_is_pro()` and `mbs_is_pub()` handle cache freshness, tamper state, and ping-age
guards that a raw option check silently misses.

**Specifically prohibited:**
```php
// WRONG — never do this
get_option('mbs_license_tier') === 'pro'
get_option('mbs_license_tier') === 'pub'
get_option('mbs_license_tier') === 'publisher'   // stale pre-Sprint-12 string
$tier = get_option('mbs_license_tier'); if ($tier === 'pub') { ... }

// CORRECT
mbs_is_pro()   // true for 'pro' AND 'pub'
mbs_is_pub()   // true for 'pub' only
```

> **B-003 / B-004 — CLOSED (Session 140, 2026-05-07)**
> Eight locations across 6 files compared against the stale string `'publisher'`
> instead of calling `mbs_is_pub()`. The tier string was renamed to `'pub'` in
> Sprint 12 but many call sites were never updated. All comparisons have been
> converted to `mbs_is_pub()`. Affected features: Publisher Supply Chain format
> fields, Territorial Rights, ARC reviewer limits, ARC dashboard cards, Team
> Activity feed, tier tile CSS class.

> **QF-005 — CLOSED (Test Suite Remediation, April 2026)**
> `MBS_HMAC::verify_response()` called `hash_equals($expected, $received)` without guarding
> against empty strings. In PHP, `hash_equals('', '')` returns `true`, so a stripped or empty
> HMAC signature would silently pass verification.
>
> **Fix applied to `includes/api/class-mbs-hmac.php`:**
> ```php
> if ( '' === $expected || '' === $received ) {
>     return false;
> }
> ```
> This guard fires before `hash_equals()` is called. Any empty string in either argument
> now returns `false` immediately. Regression test added to `test-mbs-hmac.php` (R-011).

> **`MBS_TESTING_MODE` in test bootstrap (Test Suite Remediation R-001, April 2026)**
> `tests/bootstrap.php` now defines `MBS_TESTING_MODE` as `true`. This is the safe pattern
> for enabling `mbs_is_pro` filter overrides in the test environment without production risk.
> The define lives only in the test bootstrap and is never reachable from production code.
> See SEC-02 below for the production guard.

> **SEC-02 — CLOSED (Sprint 12, Story 12-7, April 2026)**
> The `add_filter('mbs_is_pro', '__return_true')` block in `bookshowroom.php` is now
> wrapped in `if (defined('MBS_TESTING_MODE') && MBS_TESTING_MODE)`. In production —
> where `MBS_TESTING_MODE` is not defined — the filter registration is unreachable.
>
> Safe testing pattern in local/staging environments only:
> ```php
> // In wp-config.php on local/staging only — NEVER on production:
> define( 'MBS_TESTING_MODE', true );
> // Then in test code:
> if ( defined( 'MBS_TESTING_MODE' ) && MBS_TESTING_MODE ) {
>     add_filter( 'mbs_is_pro', '__return_true' );
> }
> ```
> Production `wp-config.php` must never define `MBS_TESTING_MODE`. Verified at Sprint 12
> gate: 77 `mbs_is_pro()` calls audited; 22 PRO HTML comments confirmed in canonical form.

### Rule 4 — All output is escaped

Every value output to HTML goes through the appropriate WordPress escaping function:
- `esc_html()` for text content
- `esc_attr()` for HTML attribute values
- `esc_url()` for URLs
- `wp_kses_post()` for rich text that may contain allowed HTML
- `absint()` for integer IDs

No raw PHP variable is ever echoed directly into HTML output.

### Rule 5 — All input is sanitized before database writes

Every value written to the database is sanitized:
- `sanitize_text_field()` for text
- `absint()` or `intval()` for integers
- `sanitize_email()` for emails
- `esc_url_raw()` for URLs being stored (not displayed)
- `$wpdb->prepare()` for any query with variables

No raw `$_POST`, `$_GET`, or `$_REQUEST` values touch the database.

### Rule 6 — Never expose secrets in request URLs

The update checker and all authenticated endpoints must place sensitive values (license key,
HMAC) in the POST body only. Never pass raw license keys in query strings, URL parameters,
or server-accessible log output.

### Rule 7 — Settings exports must never include state or security keys

The following keys are excluded from all settings exports via
`MBS_Settings_IO_Keys::EXCLUDED_KEYS` and must never be added to an export file:
- `mbs_license_key_encrypted` — license key
- `mbs_api_secret_encrypted` — HMAC signing secret
- `mbs_api_nonce` — current HMAC nonce; importing another site's nonce breaks all
  subsequent HMAC-signed requests on the receiving site
- `mbs_tamper_flag` — importing another site's tamper state (potentially 0) resets
  legitimate tamper detection on the receiving site
- `mbs_setting_import_amazon_api_key_encrypted` — PA-API credentials
- `mbs_setting_import_amazon_api_secret_encrypted` — PA-API credentials
- `mbs_setting_payment_stripe_webhook_secret_encrypted` — payment secrets
- `mbs_setting_payment_paypal_webhook_secret_encrypted` — payment secrets


### Rule 8 — All outbound API calls carrying credentials or PII must use HTTPS

Every `wp_remote_post()` call that transmits a license key, HMAC signature, `api_secret`,
customer email, or payment data must target an `https://` URL. Plain HTTP transmission of
any of these values is a violation regardless of whether the data is encrypted at rest.

This applies to:
- `MBS_Tamper_Detector::fetch_and_verify_manifest()` — transmits license key
- `MBS_Stripe_Adapter::call_provision_endpoint()` — transmits customer email and transaction ID
- `MBS_PayPal_Adapter::call_provision_endpoint()` — transmits customer email and transaction ID
- All `MBS_License_Client` methods — transmit license key and HMAC parameters

**Enforcement:** Each of the above methods checks `str_starts_with(MBS_API_BASE, 'https://')`
before sending. If the check fails and `MBS_DEV_MODE` is not true, the call is aborted and an
error is recorded. `MBS_DEV_MODE = true` in `wp-config.php` bypasses the guard for local
development only — see Rule 9.

> ⚠️ **Developer reminder:** `MBS_API_BASE` is currently set to the `http://` staging URL in
> `bookshowroom.php`. The HTTPS guard is bypassed by `MBS_DEV_MODE = true` during Sprints 9–15.
> Before production deployment, confirm `MBS_API_BASE` points to the `https://` production URL
> and remove the `MBS_DEV_MODE` bypass — see Rule 9.

### Rule 8b — Three install states: keyless FREE, key-stored-unactivated, activated

**WO-002 (Sprint WO-002, 2026-05-18).** `mbs_is_registered()` now returns `true` in keyless FREE mode (no key stored), enabling WP.org-compliant operation without registration.

The three install states and their gate-function return values:

| State | `mbs_is_keyless()` | `mbs_is_registered()` | `mbs_is_pro()` |
|-------|--------------------|-----------------------|----------------|
| Keyless FREE (no key stored) | `true` | `true` | `false` |
| Key stored, not activated | `false` | `false` | `false` |
| Key activated — FREE tier | `false` | `true` | `false` |
| Key activated — PRO tier | `false` | `true` | `true` |

**Security invariant:** `mbs_is_keyless()` is defined as `'' === (string) get_option('mbs_license_key_encrypted', '')`. Do NOT substitute `!mbs_is_registered()` — that returns `true` for both keyless FREE and key-stored-unactivated states and cannot distinguish them.

**Settings import gate:** Uses `get_option('mbs_license_registered', false)` directly (not `mbs_is_registered()`) to block both keyless AND key-stored-unactivated installs. This is intentional: settings import requires an activated registration.

---

### Rule 9 — `MBS_DEV_MODE` is a development bypass — never deploy to production

`define('MBS_DEV_MODE', true)` in `wp-config.php` disables multiple security gates to allow
unobstructed development and testing. It bypasses:

- The tamper detection block in `mbs_is_registered()` — tamper flag does not suppress FREE output
- The tamper detection block in `mbs_is_pro()` — tamper flag does not suppress PRO output
- The HTTPS guard in `MBS_Tamper_Detector::fetch_and_verify_manifest()`
- The HTTPS guard in `MBS_Stripe_Adapter::call_provision_endpoint()`
- The HTTPS guard in `MBS_PayPal_Adapter::call_provision_endpoint()`

The bypass exists because developer file edits trigger the tamper system, and the staging API
runs over HTTP. Both conditions are development-only.

> ⚠️ **PRE-LAUNCH REQUIRED ACTION — Rule 9**
> `MBS_DEV_MODE = true` must be removed from `wp-config.php` before any production deployment.
> Additionally, all `if (defined('MBS_DEV_MODE') && MBS_DEV_MODE)` bypass blocks must be
> removed from the plugin source files. Search the codebase for `MBS_DEV_MODE` to find every
> occurrence. Affected files as of Sprint 9:
> - `bookshowroom.php` — `mbs_is_registered()` and `mbs_is_pro()` gate functions
> - `includes/api/class-mbs-tamper-detector.php` — manifest fetch HTTPS guard
> - `includes/payment/class-mbs-stripe-adapter.php` — provision endpoint HTTPS guard
> - `includes/payment/class-mbs-paypal-adapter.php` — provision endpoint HTTPS guard
>
> Pre-launch action tracked as: Sprint 15 checklist. Execute from `PATCHES/PRE-LAUNCH-CHECKLIST.md` —
> that document is the single source of truth for all cutover tasks and verification greps.

---

## 2. Cryptographic Implementation Rules

### License Key Hashing (Server Side)
```php
// CORRECT — Argon2id with minimum parameters
$hash = password_hash($raw_key, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536,  // 64MB minimum — do not reduce
    'time_cost'   => 4,      // 4 iterations minimum — do not reduce
    'threads'     => 2,
]);

// CORRECT — verification
$valid = password_verify($submitted_key, $stored_hash);

// WRONG — never use MD5, SHA1, SHA256 for password/key hashing
$hash = hash('sha256', $raw_key); // NEVER DO THIS
```

### License Key Storage (WordPress Plugin Side)
```php
// CORRECT — AES-256-CBC using AUTH_KEY
$encrypted = MBS_Encryption::encrypt($raw_key);
update_option('mbs_license_key_encrypted', $encrypted);

// CORRECT — retrieval
$raw_key = MBS_Encryption::decrypt(get_option('mbs_license_key_encrypted'));

// WRONG — never store raw
update_option('mbs_license_key', $raw_key); // NEVER DO THIS
```

### HMAC Request Signing
```php
// CORRECT — per-license secret, timing-safe comparison
// Canonical string: license_key|domain|timestamp|nonce
$expected = hash_hmac(
    'sha256',
    $license_key . '|' . $domain . '|' . $timestamp . '|' . $nonce,
    $per_license_api_secret
);
$valid = hash_equals($expected, $received_hmac);

// WRONG — string comparison is timing-vulnerable
$valid = ($expected === $received_hmac); // NEVER DO THIS
```

### Manifest Verification (D-3 — Updated April 2026)
The file integrity manifest is verified using the per-activation `api_secret`, not a
shared plugin-wide constant. The manifest endpoint is an authenticated HMAC-signed POST.
The plugin sends its standard HMAC parameters; the server signs the manifest response with
the requesting site's `api_secret`. The plugin verifies using the same `api_secret` it
holds in `mbs_api_secret_encrypted`.

The previous per-installation random key mechanism (`MBS_MANIFEST_KEY` constant and
`mbs_manifest_signing_key` wp_option) has been removed. See Doc 0d §2.6 for the full
updated contract. See `archive/SERVER-STRUCTURE.md` for the server-side implementation specification (moved Session 105).

### Timestamp Window
```php
// CORRECT — 5-minute window, reject future timestamps beyond 60 seconds
$now = time();
$window = 300; // 5 minutes
if (abs($now - $timestamp) > $window) {
    // Reject — replay attack or clock skew
}
```

---

## 3. Anti-Tamper System

The FREE watermark anti-tamper system escalates through four levels. Developers must
understand it to avoid accidentally triggering it:

| Level | tamper_flag | Trigger | What breaks |
|---|---|---|---|
| 0 | 0 | Clean state | Nothing |
| 1 | 1 | File hash mismatch OR watermark HTML modified | Watermark re-enabled, domain logged |
| 2 | 2 | 3+ consecutive pings at level 1 | All front-end MBS output disabled |
| 3 | 3 | 7+ consecutive pings at level 2 | Full plugin disabled |

**What accidentally triggers the tamper system:**
- Modifying `templates/partials/watermark.php` without updating the file integrity manifest
- Modifying any file listed in the server manifest without triggering a re-fetch
- Testing on a local environment where the daily ping cannot reach `www.dmrstech.com`

**How to safely test tamper-related code:**
- Use the staging server (`staging-api.dmrstech.com`) — never test tamper escalation
  against production
- Reset `tamper_flag` to 0 in the `mbs_activations` table after each test
- Never test tamper escalation on a production site

---

## 4. Amazon PA-API Credential Security

The Amazon PA-API Access Key and Secret Key entered in Settings Tab 5 (Import/Export) are
stored encrypted in `wp_options`. They are **never transmitted to `www.dmrstech.com`** or
any DMRS Tech server. This is both a technical requirement and a legally important
commitment communicated to users in the FAQ.

Developer rules for PA-API credentials:
- Store as `mbs_setting_import_amazon_api_key_encrypted` and
  `mbs_setting_import_amazon_api_secret_encrypted` using `MBS_Encryption::encrypt()`
- Never include PA-API credentials in any telemetry payload
- Never log PA-API credentials at any log level
- Never transmit PA-API credentials in any HTTP request to any server other than
  `api.amazon.com`
- The PA-API Secret Key field in the admin UI must use `type="password"` — never
  `type="text"`

---

## 5. Sprint 14 Security Rules & Clarifications

### Rule S14-1 — Telemetry Payload Must Never Include Sensitive Book or License Data

The telemetry ping payload sent to `/telemetry/ping` is allowlist-controlled. The following
values must **never** appear in any telemetry payload, regardless of how the payload is
assembled:

- Book titles, author names, ISBNs, ASINs, or cover URLs
- Raw license key (plaintext)
- `api_secret` (encrypted or plaintext)
- Amazon PA-API Access Key or Secret Key
- Any `wp_options` key not in the explicit allowlist

**Plugin-side enforcement:** `MBS_Telemetry_Client::send_ping()` builds payload from
hardcoded keys only. `unset($log_body['license_key'], $log_body['signature'])` before logging.

**Server-side enforcement:** `TelemetryController::ping()` validates payload against the same
allowlist and returns HTTP 422 for any unexpected key. Server-side enforcement provides
defence-in-depth independent of plugin code.

See AUDIT-HISTORY A-002 and A-003 for the full decision rationale.

---

### Rule S14-2 — MyBookTable Import Must Strip `settings` Before Processing

`MBS_Importer_MyBookTable::import()` must call `unset($data['settings'])` as the very first
operation after JSON decoding, before any iteration or mapping. The MBT export format embeds
the site owner's MyBookTable API key in `settings.api_key`. This key must never:

- Be stored in `wp_postmeta`, `wp_options`, or any other database table
- Appear in any `error_log()` or log store
- Be referenced by any field mapping

The strip must be pre-loop and unconditional — it must execute even if the mapping array
somehow references a `settings.*` path.

See AUDIT-HISTORY A-001 for the full decision rationale.

---

### Rule S14-3 — `mbs_landing_page_mode` Must Be Double-Gated

The Book Landing Page feature (story 14-8) is PRO-only and must be enforced at two layers:

1. **Save layer** (`MBS_Child_Header_Links::save()`): when `mbs_is_pro()` is false, call
   `delete_post_meta($post_id, 'mbs_landing_page_mode')` unconditionally — even if the
   checkbox was submitted. The value must never be stored as `'1'` on a FREE install.

2. **Render layer** (`MBS_Book_Display::template_loader()`): check `mbs_is_pro() &&
   get_post_meta(get_the_ID(), 'mbs_landing_page_mode', true) === '1'` before routing to the
   landing template. This is the fallback gate in case the save layer is somehow bypassed.

Relying on render-time checks alone is insufficient. Both gates must remain in place.

See AUDIT-HISTORY A-007 for the full decision rationale.

---

### Rule S14-4 — WXR Temp Files Must Use Plugin Temp Dir; Never `wp-content/`

WXR import uploads are moved to `get_temp_dir() . 'mbs-temp-import/'` immediately after
upload validation. They must **never** be placed inside `wp-content/` or any web-accessible
directory. After `MBS_Importer_WXR::import()` completes (or on any error path), the temp
file and its containing transient must be deleted immediately.

Temp file path pattern: `get_temp_dir() . 'mbs-temp-import/' . sanitize_file_name($name)`
Transient key pattern: `'mbs_wxr_file_' . md5($file_nonce)` — TTL `HOUR_IN_SECONDS`

---

### Rule S14-5 — `iframe srcdoc` Must Be Set via DOM Property, Not `setAttribute`

When setting preview content on an `<iframe>` via `srcdoc`, always use:

```js
frameEl.srcdoc = htmlString;   // CORRECT — property assignment, no encoding applied
```

**Never** use:

```js
frameEl.setAttribute('srcdoc', htmlString);   // WRONG in this context
```

When `srcdoc` is set via DOM property, the browser treats the value as raw HTML. If the PHP
side were to apply `htmlspecialchars()` before JSON-encoding, entities would render as literal
text in the preview. The PHP handler must return raw (non-entity-encoded) HTML.

This is safe when: (a) the HTML is generated entirely server-side from trusted plugin renderers;
(b) the iframe carries `sandbox="allow-same-origin allow-scripts"`; (c) no user-controlled
content is injected into the preview HTML.

See AUDIT-HISTORY A-008 for the full decision rationale.
