Auth & limits¶
Five scopes¶
| Scope | What it unlocks |
|---|---|
qot:read |
Quote read + subscribe |
acc:read |
Account read-only (funds / positions / orders / fills) |
trade:simulate |
Simulated-account place / modify / cancel |
trade:real |
Real-account place / modify / cancel |
trade:unlock |
Used by the MCP futu_unlock_trade tool (reads the password from OS keychain, never exposed to the LLM) |
keys.json format¶
{
"version": 1,
"keys": [
{
"id": "research",
"hash": "sha256 hex",
"scopes": ["qot:read", "acc:read"],
"limits": {
"allowed_markets": ["HK", "US"],
"allowed_symbols": null, // null = unlimited
"max_order_value": 100000.0,
"max_daily_value": 500000.0,
"hours_window": "09:30-16:00",
"max_orders_per_minute": 5,
"allowed_trd_sides": ["SELL"]
},
"allowed_machines": ["fp_abc123"], // null = not machine-bound
"created_at": "2026-04-15T10:00:00Z",
"expires_at": "2026-05-15T10:00:00Z", // null = never expires
"note": "research bot"
}
]
}
Full field details: keys.json fields.
Seven-layer limits¶
An order request passes through each gate in order (any reject returns an error):
1. Market whitelist allowed_markets — is ctx.market in the list?
2. Symbol whitelist allowed_symbols — is ctx.symbol in the list?
3. Direction whitelist allowed_trd_sides — BUY / SELL / SELL_SHORT / BUY_BACK
4. Time window hours_window — local tz; cross-midnight OK: 22:00-04:00
5. Per-order cap max_order_value — qty × price
6. Rate limit max_orders_per_minute — 60-second sliding window
7. Daily cap max_daily_value — resets at UTC midnight
Coordination between the auth layer and the handler layer:
- Auth layer (middleware): market / symbol / side / value are unavailable (body not parsed). Runs only the rate + time-window global gate and commits a rate-window timestamp.
- Handler layer (business router): after body decode, full context exists.
Calls
check_full_skip_rateto run the remaining checks — skipping rate (already committed, avoid double-counting), but daily value still accumulates.
This way rate is counted once and daily value accumulates honestly.
Soft machine binding¶
# bind to this machine
./futucli gen-key --id bot --scopes trade:real --bind-this-machine
# cross-machine: on the target machine
./futucli machine-id --for-key bot
# prints fp_xxxxx
# on the issuing machine
./futucli gen-key --id bot --scopes trade:real --bind-machines fp_xxxxx
Fingerprint formula: SHA-256("futu-machine-bind:v1:" + key_id + ":" + raw_machine_id).
raw_machine_id sources:
- Linux: /etc/machine-id
- macOS: IOPlatformUUID (via ioreg)
- Windows: not yet implemented
Soft binding strength: does not stop an attacker who has logged into the
target machine (they can read machine-id and forge the fingerprint); it does
stop an accidental leak where the whole keys.json is copied to another host.
Production-grade strong binding should use TPM / Secure Enclave (future
release).
SIGHUP hot reload¶
kill -HUP <pid> re-reads keys.json. All four entry points (REST / gRPC /
core WS / MCP) support it:
- Newly-added keys work within seconds
- Edits to scope / limits /
expires_at/allowed_machinestake effect immediately (no restart) - A
remove-key'd key is rejected on the next request (no TTL wait)
Implementation: KeyStore uses ArcSwap<KeysFile>; SIGHUP → re-read file
→ atomically swap the snapshot. The request path fetches the current
snapshot via get_by_id(key_id).
Audit¶
Every allow / reject / trade event flows through futu_auth::audit:
WARN target=futu_audit iface=rest endpoint=/api/order
key_id=bot_a outcome=reject reason="limit: rate limit exceeded: ..."
Use --audit-log <PATH> to write target=futu_audit events to a separate
JSONL file, then post-process with jq / DuckDB:
Fail-closed design (against attackers)¶
| Scenario | Behavior |
|---|---|
/api/unknown-path |
404 (don't leak whether the endpoint exists, and don't suggest "try another key") |
tools/call for an unregistered tool |
Reject "unknown MCP tool" |
gRPC proto_id not in our map |
Default to trade:real scope (conservative) |
Per-call api_key invalid |
Reject (do not fall back to the startup key — prevents privilege escalation) |
| Key revoked after handshake | Next request rejected with "key revoked" |