Auth & limits¶
Legacy mode (no keys.json) behavior¶
v1.4.86 behavior change (partial breaking):
| Mode | Read-only endpoints (/api/quote / /api/positions / ...) |
Write / admin endpoints (/api/order / /api/modify-order / /api/unlock-trade / /api/reconfirm-order / /api/cancel-all-order / /api/admin/*) |
|---|---|---|
| Scope mode (keys.json configured) | Requires matching scope token | Requires trade:real / admin scope token |
| Legacy mode (no keys.json) | ✅ Allowed without auth (backward compat) | ❌ 401 UNAUTHORIZED (new in v1.4.86) |
Why: Any local skill / agent / shell script could previously curl POST
/api/order without auth, which is a security hole (external reviewer
confirmed 2026-04-23). v1.4.84 only printed a startup stderr warning;
v1.4.86 hard-gates it.
Upgrade guide: If you previously used legacy mode + write endpoints
(/api/order etc.):
# 1. Generate an API key
./futucli gen-key --id my-agent --scopes qot:read,acc:read,trade:real
# → prints fc_xxxxx plaintext (shown once, save on client side)
# → writes to ~/.futu-opend-rs/keys.json (or --rest-keys-file path)
# 2. Restart daemon with --rest-keys-file
./futu-opend --login-account X --login-pwd Y \
--rest-port 22222 \
--rest-keys-file ~/.futu-opend-rs/keys.json
# 3. Client passes the token
curl -H "Authorization: Bearer fc_xxxxx" \
-X POST http://localhost:22222/api/order \
-d '{"c2s": {...}}'
Read-only use is unaffected: if you only call /api/quote /
/api/positions / /api/orders query endpoints, keep skipping keys.json
configuration — legacy still allows them.
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) |
admin |
v1.4.32+ daemon management endpoints (/api/admin/status|reload|shutdown); grant only to ops keys, never to LLM keys |
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" |
F3C Auth error code reference (ret_type semantics)¶
Added in v1.4.84 §12 (in response to dual-tester feedback that the same
account across different versions sees different ret_type values — needs
a semantic reference). Daemon receives auth responses from auth.futunn.com /
auth.moomoo.com in POST /authority/; the ret_type / error_code meanings
(aligned with C++ FTLogin convention):
ret_type |
Name | Meaning | Trigger | Daemon behavior |
|---|---|---|---|---|
| 0 | Success | Login OK | TGTGT verified + device verified | Save tgtgt_new / device_sig_new / rand_key_new to credentials file, start server |
| 2 | Password / TGTGT sig mismatch | pwd_md5 wrong OR TGTGT misconstructed (client_type / account split / svr_time endian etc.) |
Futu protocol misleading: looks like "wrong password", 99% TGTGT construction issue | Prompt user: check password + platform (futunn vs moomoo) + --reset-device |
| 11 | require_verify_code | TGTGT sig verification failed (not a real captcha request) | Missing region_no in account split / missing salt URL fields / missing payload fields |
Verify 15 fields in POST /authority/ body (empirical) |
| 15 | tgtgt expired | 3 independent causes: (a) device_id poisoned (empty SMS submission pre-v1.4.16) (b) server throttling (same uid repeating POST /authority/) © account-level state anomaly (needs App login to activate) |
Not really "expired", 99% (b) throttling | Daemon prompts in 3 categories: sleep 60s retry → --reset-device → login via App once |
| 20 | require_device_verify | Normal SMS flow (first login from new device); response carries device_verify_sig / phone_no |
New device_id / credentials file doesn't exist | Daemon auto-calls req_device_code → SMS sent → prompt user for --verify-code |
| 21 | Verify code wrong | SMS code wrong or empty (non-interactive stdin fail) | User typed wrong / setup-only non-TTY stdin empty | v1.4.76 atty check guard + v1.4.81 Option B cached dcs retry path |
| 23 | Need phone bind | Account hasn't bound phone in App | New account without App first-login activation | Prompt user to complete phone binding in Futu/moomoo App |
| 45 | App version too low | X-Futu-Client-Version < 800 |
Overseas accounts strictly check version | Rust hardcodes 1002 (= C++ OpenD FTGTW_Client_Version = 10.02 × 100), shouldn't trigger |
| 99 | Network request param error | body signature verification failed / field missing / invalid value | Deleting required fields like sens_state (empirical, sens_state-class required fields), or body tampered |
Don't modify daemon hardcoded fields |
Cross-version ret_type stability¶
The same account may see different ret_type values across versions — this
is not necessarily a daemon regression:
| Cross-version difference | Most likely cause |
|---|---|
| 20 → 11 | device_id changed (--reset-device or file deleted) → server needs re-SMS verification |
| 20 → 15 | Throttling hit (short-period POST /authority/ exceeded threshold) / or device_id invalidated by App |
| 11 → 15 | TGTGT payload difference (version fixed a field) + server throttling |
| 2 → 11 | Platform switched (--platform futunn ↔ moomoo) causing wrong client_type 40/60 |
| 15 → 20 | After --reset-device, server sees new device and starts fresh SMS flow |
Recommendation: When real-machine verifying and seeing cross-version
ret_type flip, first sleep 300s to clear the throttle window, then confirm:
- Version number (futu-opend --version)
- --platform value
- Whether credentials file was modified externally
- Whether --reset-device was run recently
2FA error codes (/api/unlock-trade response)¶
Token 2FA (Futu token / moomoo token) backend result_code after
/api/unlock-trade (non-HTTP-auth path):
result_code |
Meaning | Fix |
|---|---|---|
| -20011 | Account needs token 2FA verification | Use the security_firm's corresponding token app (Futu token vs moomoo token, secrets incompatible) + 2FA enabled for account |
| -102 | Channel doesn't support | Account not unlocked / no permission / broker not connected |
| 110005 | Contract field inconsistency | Check security_type + exchange_str + sec_market (empirical) |