Files
wursor/spikes/pairing-threat-model.md
T
SinachPat 6e59a8a36b feat: Phase 0 workspace, spikes, and golden harness
Stand up the monorepo skeleton and land the first Phase 0 artifacts:
pairing threat model, plugin catalog, builder detect, 20-prompt golden
harness, and synthetic 2GB mirror timing.
2026-08-15 09:44:43 +01:00

142 lines
6.7 KiB
Markdown

# Spike: pairing threat model (R9)
**Status:** done
## Question
What stops a leaked URL from owning the site?
## Done when
Written threat model that becomes the Sprint 2 / Layer 2 auth tests:
- 8+ character pairing code
- 5-minute TTL
- 5-attempt lockout
- HMAC request signing
- hashed + scoped tokens (read vs deploy)
## Result
The plugin is a privileged backdoor: files, DB, WP-CLI. A leaked URL, a guessed pairing code, or a stolen bearer token is site ownership. Isolation of the *sandbox* does not help — this boundary is the *live* site.
### Locked flow
Wursor generates the pairing code (bound to the signed-in account). The user pastes it into the plugin. The plugin redeems it with the site URL. Tokens are issued once.
This matches PRD §7.1.4. The `Wursor_Auth::generate_pairing_code()` sketch in IMPLEMENTATION.md is the wrong direction — plugin-local generate/redeem cannot bind the code to an account before the site is known. Sprint 2 tests follow this note, not that sketch.
```
User (signed in) → POST /sites/pair → Wursor stores pending pairing
User pastes code in plugin admin
Plugin → POST https://api.wursor…/sites/redeem { code, site_url }
Wursor binds site_url, returns read_token + deploy_token + hmac_secret (once)
Plugin stores hashes + encrypted hmac_secret
Wursor stores tokens encrypted (it must send them later)
```
Wursor is the HTTPS client. The plugin is the server. Tokens never appear in query strings or logs.
### Protocol
**Pairing code**
- Alphabet: `[A-Z0-9]`, length ≥ 8. Generate 8. `36^8 ≈ 2.8e12`.
- Bound to `account_id` at creation. Not reusable after success.
- `expires_at = created_at + 300s`. Clock for tests is injectable (`advance_clock`).
- After 5 failed redeems on that code, `locked = true`. Further redeems fail even if the code is correct.
- Redeem also fails if `site_url` is not `https` or does not parse as a URL.
- One successful redeem. Second redeem of the same code fails.
**Tokens**
| Token | Scope | Plugin endpoints |
|---|---|---|
| `read` | site-info, file read, DB read, preflight | GET only |
| `deploy` | file write, DB write, WP-CLI, prepare/commit, rollback | mutating |
- 256-bit random, encoded unpadded base64url, shown once.
- Plugin stores `SHA-256(token)` only. Compare with `hash_equals`.
- Wursor stores ciphertext (envelope key, not plaintext in Postgres).
- A `read` token on a deploy route returns 403. A `deploy` token may call read routes.
- Rotation: Wursor issues a new pair; plugin replaces hashes; old hashes stop working.
- Disconnect: both hashes deleted; Wursor ciphertext deleted.
**HMAC (every plugin request)**
```
canonical = timestamp + "\n" + METHOD + "\n" + path + "\n" + hex(sha256(body))
X-Wursor-Timestamp: unix seconds
X-Wursor-Signature: hex(HMAC-SHA256(hmac_secret, canonical))
Authorization: Bearer <read_token|deploy_token>
```
- Reject if `|now - timestamp| > 60`.
- Reject if signature missing or `hash_equals` fails.
- `hmac_secret` is 256-bit, issued at redeem, stored on the plugin encrypted with the site salt (`AUTH_KEY` + `AUTH_SALT`). Not the same bytes as either token.
- Body hash is over the raw bytes. Empty body is SHA-256 of `""`.
**Transport**
- Plugin REST namespace: `/wp-json/wursor/v1/`.
- Plugin refuses non-HTTPS callbacks except `WP_ENVIRONMENT_TYPE === 'local'`.
- Wursor never puts tokens in URLs, logs, or SSE payloads.
### Threats
| ID | Threat | Mitigation | Residual |
|---|---|---|---|
| T1 | Attacker guesses pairing codes | 8+ charset, 5-try lockout, 5-min TTL | Online brute force is ~5 guesses / 5 min / code |
| T2 | Pairing code leaked (screenshot, chat) | TTL + single use + requires wp-admin to paste | Anyone with the code and wp-admin wins until expiry |
| T3 | Attacker redeems victim's code onto attacker site | After redeem, Wursor shows the bound `site_url` and requires an explicit “this is my site” confirm before the site is usable | User who confirms a foreign URL is connected to it |
| T4 | Bearer token in a URL / access log / Referer | Tokens only in `Authorization`. Tests fail if any helper puts them in a query | Operator error in a future client |
| T5 | Stolen request replayed | HMAC over timestamp+method+path+body; 60s skew window | Replay inside the window if the request was captured |
| T6 | Stolen `read` token used to deploy | Scoped tokens; deploy routes require `deploy` | Read token still exfiltrates site-info |
| T7 | Plugin DB dump / filesystem copy | Plugin stores hashes + encrypted hmac_secret, not raw tokens | Wursor-side ciphertext leak still lets us *call* the plugin until rotation |
| T8 | MITM on HTTP | HTTPS required except local | Mis-set `WP_ENVIRONMENT_TYPE` on a public HTTP site |
| T9 | CSRF in the browser against plugin REST | Bearer + HMAC. No cookie auth for `/wursor/v1/` | None if those headers stay required |
| T10 | Timing leak on token compare | `hash_equals` only | — |
Out of scope for this spike (handled elsewhere): stolen wp-admin session, compromised host, malicious plugin already on the site.
### Sprint 2 tests (this note is the spec)
`plugin/__tests__/test-auth.php`
1. `test_pairing_code_is_at_least_eight_alnum``^[A-Z0-9]{8,}$`
2. `test_pairing_code_expires_after_five_minutes``advance_clock(301)` → redeem false
3. `test_pairing_code_valid_at_four_minutes_fifty_nine``advance_clock(299)` → redeem true
4. `test_locks_out_after_five_failed_attempts` — five bad redeems → `is_locked_out()`
5. `test_lockout_rejects_even_the_correct_code`
6. `test_successful_redeem_cannot_be_replayed`
7. `test_read_token_hash_is_stored_not_plaintext`
8. `test_read_token_forbidden_on_deploy_route` → 403
9. `test_deploy_token_allowed_on_site_info`
10. `test_hmac_rejects_stale_timestamp` — timestamp older than 60s
11. `test_hmac_rejects_tampered_body`
12. `test_hmac_rejects_missing_signature`
13. `test_verify_uses_hash_equals`
14. `test_rotated_tokens_invalidate_old_hashes`
`api/__tests__/services/plugin-client.test.ts`
1. signs every request with timestamp + HMAC
2. sends token in `Authorization`, never in the URL
3. maps 401 to `Authentication failed`
4. refuses to construct a client with an `http://` site URL outside local
`api/__tests__/routes/sites-pair.test.ts`
1. pair requires a session
2. redeem binds `site_url` and returns tokens once
3. second redeem of the same code fails
4. site is not `connected` until the user confirms the shown URL (T3)
### Decision
- **Context:** plugin can own the live site; old 6-char sketch had no TTL, lockout, HMAC, or scopes.
- **Options:** plugin-generated code (TV pairing) vs Wursor-generated code (PRD).
- **Chosen:** Wursor-generated, pasted into the plugin, HMAC + scoped tokens as above.
- **Rejected:** plugin-local generate/redeem (cannot bind to account first; IMPLEMENTATION sketch). Tokens in query strings. Single unscope token.
- **Reverted later?**