mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 22:48:05 +00:00
248 lines
12 KiB
Markdown
248 lines
12 KiB
Markdown
# Server-issued sweep keys — escalation plan
|
||
|
||
Status: **specified, deliberately not built.** The shipped local rate gate
|
||
(`docs/sweep-combo-limit-plan.md`) measures 9.5x–16.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.5x–16.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.
|