Files
manifoldbt/docs/sweep-key-rate-limit-plan.md
T
2026-08-16 12:02:58 +00:00

248 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Server-issued sweep keys — escalation plan
Status: **specified, deliberately not built.** The shipped local rate gate
(`docs/sweep-combo-limit-plan.md`) measures 9.5x16.7x at a 256-combo cap, and
lowering the cap further scales it linearly — the level this design was meant to
buy,
without a server, without making the free tier online-only, and without a
privacy review.
Build this **only** if telemetry shows the local gate being defeated at scale.
What would justify it: evidence of `faketime`/`LD_PRELOAD` clock manipulation
or per-slice namespaces in the wild, i.e. the two attacks the local gate cannot
answer. Nothing else here is worth the cost.
The rest of the document is the spec, kept complete so the decision can be
revisited without redoing the analysis.
## 1. What it does
A sweep must present a valid, unexpired, server-signed key. The key carries a
**budget of combinations**, not a single authorisation, so a burst of small
sweeps shares one round-trip.
| tier | rate | combos per key | TTL | round-trips |
|-----------|-----------------|----------------|-------|-----------------------|
| Community | 1 key per 5 s | 100 | 30 s | 1 per 100 combos |
| Pro | none | unlimited | 24 h | 1 per day |
Expected effect, from the measured Pro baselines (3 months of 1m bars, 4 cores):
| combos | Pro | Community floor | friction |
|--------|--------|---------------------|----------|
| 5,000 | 8.0 s | 50 keys → 4.2 min | ~31x |
| 20,000 | 26.6 s | 200 keys → 16.7 min | ~38x |
These hold on any machine, strategy and dataset size — but so does the shipped
local rate gate, for the same reason (a wall-clock floor rather than CPU work).
The server's only remaining advantage is that its clock is out of the user's
reach: no `faketime`, no namespace trick. That is the entire delta now.
## 2. Why the server, and why no offline lane
Every client-side variant was built or analysed and fails the same two ways
(measurements in the companion doc):
- **State dies with the process.** The cumulative counter resets on restart. On
disk it is one `rm` away; as a required "ticket" file it is one `cp` away —
a MAC prevents forgery, not replay, and a *stale* restored file is more
permissive, so replay always favours the attacker.
- **A fixed client *CPU* cost buys a variable ratio.** Friction was
`1 + fixed_cost / (Pro cost of the slice)`, so the CPU penalty gave 6.2x on a
light strategy and 3.9x on a heavy one. (The lock-serialised wall-clock gate
that replaced it does not have this defect — it is why this plan is no longer
urgent.)
**Why no offline lane.** "Free users get N combos offline, the key is only
needed beyond that" sounds like a friendly compromise and is worth nothing: the
offline allowance resets on restart, so an abuser stays in the offline lane
forever and never requests a key. Any tolerated offline mode is a hole exactly
the size of the allowance. The choice is binary — accept the network
requirement, or keep the local gate's 9.5x16.7x.
## 3. Protocol
```
client server
|-- POST /api/sweep-key ---------------->| { device_hash, license_hash?,
| | version, requested_combos }
| | identify -> rate + quota check
|<---------- signed key -----------------| { combos, expires_at, device_hash,
| | nonce, tier, sig }
| verify sig (embedded public key) |
| spend from the budget as sweeps run |
```
**Key payload** (JSON, serialised canonically — reuse the existing licence rule
that payloads re-serialise byte-identically so signatures verify):
```json
{ "combos": 100, "expires_at": "2026-08-11T09:31:04.000Z",
"device_hash": "…", "nonce": "…", "tier": "community" }
```
Signed Ed25519, verified against a public key compiled into the wheel. Reuse
`bt-license`: `ed25519-dalek` is already a dependency, `license.rs` already
verifies this exact shape, and `PUBLIC_KEY_BYTES` is already stored XOR-masked
and split rather than as a contiguous blob. **Use a separate key pair from the
licence one** so a leaked sweep-signing key cannot mint licences.
`device_hash` is inside the signature, so a key copied to another machine is
rejected locally, with no round-trip.
### Why the two TTLs differ
The 5 s rhythm is **not** enforced by the TTL — the server refuses to mint
before 5 s have passed, which is server state. The short Community TTL exists
to prevent **stockpiling**: with a one-hour TTL an abuser requests a key every
5 s for an hour, banks 720 keys, then burns them in parallel and gets 72,000
combinations at once. A 30 s TTL makes an unused key worthless while still
covering a burst of small sweeps.
Pro's 24 h TTL is the opposite trade: one round-trip, then a full day offline.
Its exposure is key sharing, already covered by the device binding in the
signature plus the existing per-licence device limit.
### Rate limiting caps throughput, not volume
At one key per 5 s a patient Community user still reaches ~72,000 combinations
per hour. If the intent is a ceiling rather than a drip, the server must also
hold a **daily total per account**. Recommended: yes, with a generous limit
(e.g. 5,000/day) — it costs one counter and turns "slow" into "bounded".
## 4. Client implementation
All in `crates/bt-license` (transport, crypto, state) and
`crates/bt-python/src/lib.rs` (the gate).
**4.1 — `bt-license`: new `sweep_key` module**
- `pub fn acquire(requested: u32) -> Result<SweepKey, LicenseError>`
- Returns the process-cached key if unexpired with budget left.
- Otherwise `POST /api/sweep-key`, verify signature, verify `device_hash`
matches `device::device_hash()`, cache, return.
- **Cache in memory only, never on disk.** An on-disk key is a stockpile, and
the TTL is short enough that persistence buys nothing.
- Reuse the telemetry HTTP pattern (`reqwest` + a current-thread tokio runtime),
but **blocking** with a **500 ms timeout** — a library that freezes on its
first call gets uninstalled. Distinguish the three failures explicitly:
network unreachable, rate-limited (HTTP 429 + `retry_after`), quota exhausted
(HTTP 402).
- Clock skew: trust `expires_at` against local time but also keep a local
monotonic deadline from receipt; use whichever expires first, so a client
clock set backwards cannot extend a key.
**4.2 — `bt-python`: replace the gate**
`require_combo_limit(py, n_combos, label)` becomes `require_sweep_budget`:
1. Pro (`check_feature("sweep")`) → acquire/refresh the 24 h key, return.
2. Community → `sweep_key::acquire(n_combos)`; on success debit the budget, on
failure raise `PermissionError` with the server's reason.
3. Keep the in-process cumulative counter as a **local fast-fail** so an
over-budget call fails without a round-trip.
4. Release the GIL around the request (`py.allow_threads`).
Call sites are unchanged: `run_sweep`, `run_sweep_lite`, `run_batch`,
`run_batch_lite`, `run_sweep_2d`, `run_stability`. A single `bt.run()` stays
ungated.
**4.3 — Error messages.** Three distinct, actionable texts:
- rate-limited → `"Community sweeps are limited to one every 5 s (4 s
remaining). Upgrade at …"`
- daily quota → `"Community daily sweep quota reached (5,000 combinations).
Resets at 00:00 UTC. Upgrade at …"`
- offline → `"Sweeps require a connection to www.manifoldbt.com on the
Community tier. bt.run() and single backtests work offline."`
**4.4 — Remove** `community_rate_gate`, `SWEEP_MIN_INTERVAL` and the lock file.
The server rhythm replaces the local one; running both would charge the wait
twice.
## 5. Server implementation
- `POST /api/sweep-key` — authenticate (licence hash when present, device hash
otherwise), enforce rate + daily quota, sign, return. Reject unknown or
revoked licences.
- Storage: per-device `last_issued_at` and per-account `combos_today`. A single
small table; the rate check is one read + one write.
- **Rate-limit by IP as well as by device.** Rotating `device_hash` to mint a
fresh identity is the one bypass this design does not close; per-IP limits
and requiring an account for Community keys raise the bar substantially.
- Metrics to emit from day one: keys issued, refusals by reason, distinct
devices per account, combos per account per day. This is also the first real
usage data on the free tier.
- Key rotation: publish the sweep-signing public key with a version byte in the
payload so a future rotation does not break older wheels.
## 6. Rollout
1. **Ship telemetry first.** Add sweep-usage reporting to the current wheel and
watch for a release. If the data shows nobody is chaining sweeps, stop here
and keep the client-side gating — this design is not free.
2. **Server first, enforcement later.** Deploy the endpoint and let the client
request keys *without* enforcing, logging refusals it would have made.
Confirms capacity and false-positive rate against real usage.
3. **Enforce behind a version gate.** Only wheels ≥ the release that ships this
require a key; older wheels keep working, so the change cannot brick an
existing install.
4. **Announce before enforcing.** Community losing offline sweeps is a
user-visible regression and must be in the release notes, not discovered.
## 7. Non-technical prerequisites
- **Privacy.** Unlicensed installs will contact the server for the first time —
the current telemetry ping only fires when a licence is present
(`guard.rs: if let Some(ref lic) = license`). Needs a privacy notice, a
documented retention policy, and a GDPR review. `device_hash` is a salted
hash of a machine ID: pseudonymous, not anonymous.
- **Availability becomes a product dependency.** If the endpoint is down, no
Community user can sweep. Needs an uptime target, and a documented
fallback decision (fail-closed is what makes the design hold; a fail-open
switch is a bypass anyone can trigger by blocking a domain).
## 8. Attack surface after this lands
| attack | cost after |
|-------------------------------|-----------|
| loop inside one process | blocked by the key budget |
| fresh process per slice | useless — the server clock does not reset |
| copy the key file | no key file exists; in-memory only |
| replay a captured key | 30 s TTL, bound to the device |
| forge a key | Ed25519 |
| stockpile keys | TTL shorter than the accumulation window |
| parallelise | useless — the rate is per account, not per process |
| rotate `device_hash` | **works** — mitigated by per-IP limits and account requirement, not closed |
| block the network | fails closed: no sweep |
| patch the wheel | **works** — the ceiling of any client-side check |
The last two lines are the honest limit: this makes bypassing a deliberate,
technical act rather than a side effect of restarting Python.
## 9. Tests
- Community: two sweeps back to back — the second refused until 5 s elapse;
101 combinations in one call refused outright; a burst of five 20-combo
sweeps consumes one key, not five.
- Key expiry: a key held past its TTL is refused and transparently renewed.
- Signature: edited payload rejected; valid key minted for another
`device_hash` rejected.
- Clock: local clock moved backwards does not extend a key (monotonic deadline
wins).
- Pro: one key covers a 200,000-combo sweep; no rate limit; no penalty.
- Server unreachable: fails within the timeout with the offline message, never
hangs.
- Restart: a fresh process cannot obtain a key faster than the server rhythm —
the end-to-end property the whole design exists for. Same harness as the
400-slice bypass benchmark used for the CPU penalty.
## 10. Still to decide
1. Daily quota: on or off, and at what value.
2. Rate: 5 s is a guess. It sets the free tier's usable throughput; pick it
from the telemetry in step 6.1 rather than from intuition.
3. Whether Community keys require a registered account (email) or just a
device hash. Accounts make per-IP limits far more effective, at the cost of
a signup wall in front of a free tier.