Skip to content

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_rate to 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_machines take 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:

jq 'select(.outcome=="reject" and .iface=="grpc")' /var/log/futu-audit.jsonl

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"