Threat model

View Source

asobi is one Erlang/OTP node holding the game backend, the Lua runtime and the operator console. One VM owns the match and world processes, the public ETS tables and the console session store. Single-node is the default posture and the assumptions below are written for it; what a cluster changes is a short list further down.

Trusted and untrusted

ComponentStatusNotes
asobi codetrustedthis repo
Game module in Erlang (Mod:tick/1, Mod:join/2, ...)trustedcallbacks run inline in the match process. A crash restarts the match (transient, intensity 10 / period 60)
NIFstrusteda misbehaving NIF crashes the BEAM
Pluginstrustedplugins see every request and can reach public ETS
Lua under /app/gamesandboxed, author-trustedmechanics in Sandbox model, boundary in Trust model
HTTP bodies and WebSocket payloadsuntrustedvalidated in the controllers and asobi_ws_handler
Bearer tokens, provider claims, IAP receiptsuntrustedverified by asobi_auth_plugin, asobi_oauth_controller, asobi_iap
Console bundle at /consoleunauthenticated by designthe console route group in asobi_router is declared security => false: an index document, content-hashed assets and the login endpoint. No game data passes through it
Ops callers on /api/v1/ops/*untrusted until an actor resolvesasobi_ops_auth:verify/1 resolves an actor or returns 403 with one body for every cause

The ops plane admits three credential sources, all resolving to the same actor shape: the operator secret presented as a bearer token (static_secret), a console session cookie plus its x-csrf-token header (local_user), and a short-lived token minted by asobi_saas (cloud). A player bearer token is never one of them - asobi_ops_auth does not consult the player auth cache at all. The mechanism, the environment variables and the credential handling live in Operator console.

Both surfaces answer on the game port, and they are gated separately. console gates /console alone. The ops routes are always mounted and the credential is the only thing standing in front of them, so an ops_secret set for any reason exposes /api/v1/ops/* whether or not the console is on. Unsetting the secret is what closes the plane; a stock node has none. Core's ops routes are all reads, so the blast radius of a leaked secret is disclosure plus whatever actions an installed extension declares behind /api/v1/ops/ext/:extension/:action.

Erlang distribution

config/vm.args.src boots with -name and -setcookie. EPMD listens on 0.0.0.0:4369, the distribution port range is unbounded, and the cookie is the only protection: anyone who can reach the port with the right cookie has code execution in the VM.

The published image ships a fixed, publicly known ERLANG_COOKIE=asobi (Dockerfile). It is set because relx renders an empty value otherwise and bin/asobi rpc stops working, not because it is a secret. Any deployment that exposes the distribution port must override it.

For a single node, uncomment the localhost bind in vm.args.src:

-kernel inet_dist_use_interface "{127,0,0,1}"

What changes under a cluster

Clustering is opt-in through asobi_cluster. It moves three things in this model:

  • Distribution stops being optional, so the cookie and the dist port range become load-bearing. Constrain the range and turn on TLS for distribution: -kernel inet_dist_listen_min 9100 inet_dist_listen_max 9105, -proto_dist inet_tls, -ssl_dist_optfile /etc/asobi/ssl_dist.config.
  • Several bounds in this model are per node, so a cluster of N multiplies them by N. Rate-limit buckets are the ones with security weight.
  • Console sessions and their CSRF secret are per node, so the console needs a sticky route.

Clustering holds the complete list of what is and is not shared between nodes.

Public ETS tables

These tables are public and hold live game state:

TableCreated byNamed
asobi_world_stateasobi_world_supyes
asobi_player_worldsasobi_world_supyes
asobi_match_stateasobi_match_supyes
asobi_chat_registryasobi_chat_supyes
asobi_zone_mgrasobi_zone_manageronly when a name option is passed, and then under that atom. Otherwise the table is unnamed and reached by reference
asobi_terrain_cacheasobi_terrain_storeno

Anything in the same BEAM - game callbacks in Erlang, plugins, NIFs - can read, mutate or delete entries. asobi accepts that because all in-VM code is trusted above. Lua never reaches them: Luerl scripts are not given ETS access, and the game.* bridge is the only path from a script into host state.

UUIDv7 ids carry a timestamp

asobi_id:generate/0 produces UUIDv7 (jhn_uuid), which embeds a millisecond timestamp in the high 48 bits. Match ids, world ids, ticket ids and player.id all use it. player.id is the long-lived case: the timestamp inside it reveals account-creation time. That is acceptable for a game backend, but worth knowing before you build a feature on top of it.

For an unguessable, non-correlatable id - auth tokens, invite codes - use crypto:strong_rand_bytes/1, not asobi_id:generate/0.

What the supervisors tolerate

asobi_match_sup runs each match with restart => transient under intensity 10 / period 60. Past 10 crashes in 60 seconds the match supervisor itself exits and asobi_sup restarts it, taking every live match on the node with it. That is deliberate: an obviously broken game module should stop, not churn quietly.

asobi_world_lobby_server serialises find_or_create/1 through a single process so two concurrent calls for the same mode cannot both create a world.