Phase 12: realm key pinning, linked accounts stay linked, control protocol and registry protocol 2 documentation, client credential audit tool

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-10-07 23:04:48 +05:00
1 parent 2caa687490
commit 461ffddd0d
8 files changed
+822 -81

No files matched your search

+17 -2
View File
@@ -170,8 +170,18 @@ impl PlayerControl {
Ok(self.store()?.player_realm(&realm.to_string())?.map(|r| (r.username, r.kind)))
}
/// Open a verified channel to the realm's Host. The realm's key is pinned the first time: the Registry says which key a realm id has, and a Registry (or anything between) that later
/// names another key for the same id is refused here, before anything is sent.
pub fn connect(&self, target: &RealmTarget) -> R<PlayerChannel> {
Ok(PlayerChannel::connect(&target.coordinator, &target.realm_id, &target.key, &self.identity, &clock)?)
let id = target.realm_id.to_string();
let key = coord::encode_public_key(&target.key);
match self.store()?.realm_pin(&id)? {
Some(pinned) if pinned != key => return Err(ControlFail::Link(LinkError::Auth("this realm's key is not the one it had when you first joined it".into()))),
_ => {}
}
let channel = PlayerChannel::connect(&target.coordinator, &target.realm_id, &target.key, &self.identity, &clock)?;
self.store()?.realm_pin_set(&id, &key)?;
Ok(channel)
}
pub fn welcome(&self, ch: &mut PlayerChannel) -> R<(bool, bool, Option<String>)> {
@@ -204,7 +214,12 @@ impl PlayerControl {
}
})?;
self.save_credentials(realm, &Credentials { username: username.clone(), password })?;
self.store()?.player_realm_set(&realm.to_string(), &username, AccountKind::Generated)?;
// an account the player linked stays a linked one when the realm confirms it again
let kind = match self.store()?.player_realm(&realm.to_string())? {
Some(known) if known.username == username => known.kind,
_ => AccountKind::Generated,
};
self.store()?.player_realm_set(&realm.to_string(), &username, kind)?;
Ok(AccountOutcome { username, created, password_reset: reset })
}
+15
View File
@@ -39,6 +39,11 @@ CREATE TABLE IF NOT EXISTS player_realm(
kind TEXT NOT NULL CHECK (kind IN ('generated', 'linked')),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS realm_pin(
realm_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
first_seen TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS player_claim(
realm_id TEXT NOT NULL,
character_id TEXT NOT NULL,
@@ -219,6 +224,16 @@ impl ControlStore {
Ok(())
}
/// The public key a realm had when this player first reached it. A realm id never changes its key (a new key is a new realm), so a different one is refused.
pub fn realm_pin(&self, realm_id: &str) -> Result<Option<String>> {
self.conn.query_row("SELECT public_key FROM realm_pin WHERE realm_id = ?1", [realm_id], |r| r.get(0)).optional().map_err(db)
}
pub fn realm_pin_set(&self, realm_id: &str, public_key: &str) -> Result<()> {
self.conn.execute("INSERT OR IGNORE INTO realm_pin(realm_id, public_key, first_seen) VALUES (?1, ?2, ?3)", params![realm_id, public_key, now()]).map_err(db)?;
Ok(())
}
pub fn player_claim_add(&self, realm_id: &str, character: &Uuid, name: &str) -> Result<()> {
self.conn.execute("INSERT OR IGNORE INTO player_claim(realm_id, character_id, name, claimed_at) VALUES (?1, ?2, ?3, ?4)", params![realm_id, character.to_string(), name, now()]).map_err(db)?;
Ok(())
+201
View File
@@ -0,0 +1,201 @@
# Control plane (Phase 12)
How a player's Manager gets a game account on a realm, links one it already has, and turns a character that exists on that realm into a portable one, **without the central node ever
seeing a password, a character or a database credential**. This is the *control* plane: there is no game traffic here (that is the Relay, which does not exist yet), and the Manager does not
type into the game.
Code: `crates/coa-control-proto` (the protocol: pure, no I/O), `services/coa-coordinator` (the Coordinator), `crates/coa-core/src/control` (Host and Player sides),
`src-tauri/src/control_cmds.rs` and `src/screens/JoinPanel.tsx` (the interface). End-to-end tests: `services/coa-control-e2e`. Deployment: [REGISTRY_DEPLOYMENT.md](REGISTRY_DEPLOYMENT.md).
`CONTROL_PROTOCOL_VERSION = 1`; it is independent of the Registry protocol (2) and of the portable formats.
## 1. Shape
```text
Player Manager --- WebSocket ---> Coordinator <--- WebSocket (opened by the Host) --- Host Manager
\_____________ Noise_XX end-to-end channel, authenticated by Ed25519 signatures over its handshake hash ____________/
```
* The **Host Manager** (the one that runs the realm) keeps one *outbound* connection per published realm to `/coord/v1/host`. It needs no open port.
* A **Player Manager** connects to `/coord/v1/player?realm=<RealmId>`; the Coordinator routes it to that realm's Host by a small connection number.
* Both sides then run a **Noise** channel through the Coordinator. The Coordinator forwards opaque frames; it can neither read nor change them, nor pretend to be either side.
* The realm key (Ed25519, registered with the Registry in Phase 10) is what proves "this Host is the realm in the list". The player key (Ed25519, created on the player's machine) proves "this is that player".
What each party learns:
| | learns | does **not** learn |
|---|---|---|
| **Coordinator** | which realm id, which player id and public key connected, source address, when, for how long, how many bytes | any account name, password, character, database or console credential, any request or answer (all inside the channel) |
| **Registry** | unchanged by Phase 12 (the Coordinator reads one realm's public key from its database through a four-column, read-only role) | anything about players |
| **Host Manager** | the player's id and public key, the account it created or linked for that player on its own realm, the password *for that realm* (it sets it: unavoidable, and unique per realm), the characters the player claims there | the player's other realms' credentials, the player's private key |
| **Player Manager** | its own credentials per realm, characters it claims | the Host's database or console credentials, other players' data |
## 2. Identities
* **RealmId + realm key**: Phase 10 (`docs/REGISTRY_PROTOCOL.md` section 2). The Registry publishes the key with the realm (`GET /registry/v2/realms/{id}` → `public_key`). The Coordinator checks a Host's hello against the
key it reads from the Registry's database; a player checks the Host's proof against the key in the record **and pins it at the first visit**: a different key for the same realm id later is refused before anything
is sent (a realm never changes its key; a new key is a new realm id). So a Registry that is compromised *after* a player first joined a realm cannot redirect that player.
* **PlayerIdentity**: a random `PlayerId` (UUIDv7) and an Ed25519 key, created on first use and kept in the `SecretStore` (section 8). The id is public (a Host records it); the key never leaves the machine.
A key that cannot be read is an error; it is never silently replaced by a new identity (a new identity would own none of the player's accounts and characters).
* **Host-side binding**: the first time a PlayerId gets an account on a realm, the Host records the public key the player presented. From then on that PlayerId is only accepted with that key
(`Invalid`: "This player id belongs to another key on this realm", and the channel is closed before any request). A PlayerId is a pseudonym, not a secret; a stolen id is useless without the key.
## 3. The Coordinator protocol
`GET /coord/v1/health` → `{"status":"ok","protocol_version":1,"hosts":N}`. `GET /coord/v1/host` and `GET /coord/v1/player?realm=<RealmId>` upgrade to WebSocket. Text frames are JSON control frames (strict:
unknown fields are refused, ≤ 2048 bytes); binary frames are `[connection number: u32 big-endian][one end-to-end frame, ≤ 20 KiB]`.
Both endpoints start the same way: the Coordinator sends `{"t":"challenge","protocol":1,"nonce":"<base64url of 32 random bytes>"}` and the peer must answer within 10 s.
Host hello: `{"t":"host_hello","realm_id":…,"ts":<unix s>,"sig":…}`, the signature (Ed25519 of the realm key, strict) over the UTF-8 bytes
```
coa-coord-host-v1\n<protocol>\n<realm id>\n<nonce>\n<ts>
```
Player hello: `{"t":"player_hello","player_id":…,"public_key":…,"realm_id":…,"ts":…,"sig":…}`, signature (proof of possession of the key in the frame) over
```
coa-coord-player-v1\n<protocol>\n<player id>\n<public key base64url>\n<realm id>\n<nonce>\n<ts>
```
The nonce is the Coordinator's own, fresh per connection, so a hello cannot be replayed; the timestamp must be within 120 s of the Coordinator's clock. The Coordinator accepts a Host only for a realm that is
published (protocol 2) and whose Registry key verifies the hello; a Player's hello only proves the player holds *a* key: whether it is the key of that PlayerId is the Host's decision (section 2).
After a good hello: the Host gets `host_ready`; a Player gets `player_ready{conn}` (the Host got `open{conn}` first). Either side ends a channel with `close{conn,reason}`; a Host that reconnects replaces its old
connection (its channels are closed with `host_offline`). Errors: `bad_hello`, `unknown_realm`, `host_offline`, `host_busy`, `rate_limited`, `too_large`, `timeout`, `internal`.
Limits (enforced by the service, unit- and integration-tested): 16 simultaneous channels per Host, 8 connections per address, 60 new channels per minute per realm and 30 per address, 16 MiB through one channel, a channel
lives at most 600 s and at most 60 s silent (the Host has 10 s to answer a new one), frames ≤ 20 KiB (a larger one cuts the channel), an unexpected text frame on an established channel ends it. The Coordinator keeps no state on disk.
## 4. The end-to-end channel (the exact handshake)
`Noise_XX_25519_ChaChaPoly_BLAKE2s` (the Noise Protocol Framework, implemented by the `snow` crate; nothing is invented). Both sides use **fresh random X25519 static keys for every channel**, so the Noise layer gives
confidentiality, integrity and forward secrecy but no identity. Identity is added by **channel binding**: after the handshake each side signs the handshake hash `h` (unique to this channel and equal on both ends) with its long-lived
Ed25519 key.
```text
Player -> Host msg1 (e) (binary frame, ≤ 1 KiB)
Host -> Player msg2 (e, ee, s, es)
Player -> Host msg3 (s, se) the channel is open; h = the handshake hash
Host -> Player HostProof = {realm_id, sig = Ed25519_realm_key("coa-ctl-host-v1\0" ‖ h ‖ realm_id)} sent encrypted
the Player verifies it against the realm key from the Registry (pinned) -- if it fails nothing is sent
Player -> Host PlayerProof = {player_id, public_key, sig = Ed25519_player_key("coa-ctl-player-v1\0" ‖ h ‖ realm_id ‖ player_id)}
the Host verifies it, and that the key is the one it knows for this player id
Player <-> Host requests and responses (JSON), one at a time, in order
```
A Coordinator that tries to sit in the middle would have to run two handshakes, each with its own `h`; the Host's signature covers only the Host's `h`, so the Player rejects it. The proofs of one channel are useless on another.
Messages are split into frames of ≤ 16 KiB of plaintext (+ a flag byte + the 16-byte tag, so ≤ 20 KiB with the connection number), reassembled up to 8 MiB; a frame that does not authenticate ends the channel.
Application messages: requests ≤ 4096 bytes, strict JSON with unknown fields refused, at most 60 requests per channel, 300 s per channel at the Host (idle 45 s).
## 5. Application messages
| Request | Answer | |
|---|---|---|
| `hello{protocol, client}` | `welcome{realm_id, automatic, existing_only, route}` | version check and what the realm offers |
| `provision{desired, password, have_credentials}` | `provisioned{username, created, reset}` | get or reuse the player's account |
| `link{login, password}` | `linked{username}` | one-time link of an account that already exists |
| `list_characters` | `characters{characters}` | the characters of the player's account (opaque per-connection handles; the realm's own numbers never leave the Host) |
| `claim{token}` | `claimed{character_id, sha256, payload, collections}` | export one character (section 7) |
| `claim_ack{character_id}` | `done` | the player stored it; the claim is final |
| `route` | `route_info{address}` | the address the game client can use right now, if the owner stated one |
Errors (`error{code,message}`): `unsupported_version`, `invalid`, `provisioning_off`, `no_account`, `not_at_character_select`, `not_yours`, `already_claimed`, `not_eligible`, `wrong_credentials`, `account_taken`, `rate_limited`, `unavailable`.
Backend failures are logged on the Host and answered as `unavailable` without internals.
## 6. Accounts
**Automatic provisioning is the default** (a realm's owner can switch it off: *Settings → Public listing → Do not create accounts for new players*, advertised as `existing_only`).
* The player's Manager makes a **unique random password per realm**: 16 characters of `A–Z0–9` from the operating system's randomness with rejection sampling (about 82 bits). The game compares passwords case-insensitively (SRP6 uppercases them),
so letters carry no extra strength. It is saved in the `SecretStore` *before* it is sent, so a crash between the Host creating the account and the player learning of it leaves a password the next attempt sets again.
* The player's **preferred username** (a setting; default `PLAYER`) is made fit for the game: letters and digits, upper case, ≤ 16 characters. If the name is taken, the Host adds a stable suffix derived from the PlayerId:
`DMITRY` → `DMITRY_7K4M` (four characters from a 32-letter alphabet without look-alikes; the same player gets the same alternative; attempt n gives another). Up to 10 alternatives are tried.
* The account is created through the realm's **supported path**: the world console's `account create <name> <password>` over RA (and `account set password` for a reset). **No verifier, salt or other field of the login
database is written by the Manager.** Account names may contain `_` inside (`DMITRY_7K4M`); the Manager's own validation accepts it.
* The Host records `(realm, PlayerId) → (account id, name, kind, public key)` in `control.sqlite`. It never stores the password. The Player records the name in its control database and the
password in the `SecretStore` (`realm-<id>`).
* Returning: the Host finds the mapping and answers `created:false`. A player who lost the saved password (reinstalled Manager) gets `reset:true` and a new password for the *same* account. An account an administrator deleted is created again.
* **Existing accounts** (`existing_only`, or an account from before Phase 12): the player enters name and password once; they go to the Host inside the channel and are checked against the realm's own stored SRP6 verifier
(the Host computes the verifier from the name, the password and the stored salt and compares in constant time; internal and companion accounts are refused). On success the account is bound to that PlayerId; the plaintext is not stored on the
Host, and failed attempts are rate-limited (5 per 10 minutes per player, 30 per realm). An account belongs to one PlayerId; a PlayerId has one account per realm.
## 7. Claiming a character that already exists on a realm
The player logs in to the realm with the game, stays at the **character selection screen**, switches to the Manager (Alt+Tab), opens the server in *Servers*, *Characters on this server*, and presses **Make portable** next to the character.
There is no GUID, UUID, claim code, RA or database credential anywhere in this flow.
The Host (`HostService`) checks, in this order, before it reads anything:
1. the PlayerId has an account on this realm (else `no_account`) and the character handle came from this channel's own listing;
2. **the account is online in the world session and has no character in the world**: `account.online` is set by the worldserver when a session authenticates, and no `characters.online` row exists for the account → "at the character-select screen";
anything else is `not_at_character_select`;
3. **the character belongs to that account** (`not_yours`) and is **offline and eligible** (`not_eligible`: online, deleted, bot account, challenge or game-mode states, unknown tables, …);
4. **no other PlayerIdentity has claimed it** (`already_claimed`), and it is not already portable on this Host by other means.
Then the Host exports the character with the same code as *Make portable* (one consistent snapshot), registers it with its **host** store, and sends `claimed`: the encoded `PortableCharacter` (zstd JSON, base64), its content hash, and the account's
appearance and vanity collections, all inside the channel. The Player's Manager decodes it **with verification** (hash, format, that the character id is the one claimed), stores it as **canonical revision 1** with the collections merged into
its profile, and only then sends `claim_ack`; the Host marks the binding `PortableCharacterId ↔ PlayerIdentity` as acknowledged. An unacknowledged claim can be repeated by the same player and returns the same character; nobody else can take it.
## 8. Secrets on the player's machine
`SecretStore` (`control/secrets.rs`) keeps the identity key and the realm passwords; nothing secret is in JSON, SQLite, a log or a diagnostic (`control.sqlite` holds names only, checked by the gate).
* **Windows**: one file per secret holding a **DPAPI** blob (`CryptProtectData`, current-user scope, application entropy `coa-server-manager/secret-store/v1`, written with the file's permissions restricted first). Only the same
Windows user on the same machine can open it; a copy elsewhere is useless; a tampered blob is an error.
* **Other systems**: a file with mode 0600. **This is not encryption** (anybody who can read the user's files can read it); the interface says so (*Secrets are kept in a protected file, not encrypted by the system*). A desktop
keyring (Secret Service / Keychain) is the right store there and is not implemented.
* **Limitation that remains**: the realm's own signing key (`registry/keys`, Phase 10) is still a restricted file, not DPAPI. It belongs to the Host role and moving it into the `SecretStore` is a small follow-up (it is behind its own `KeyStore` trait).
## 9. JOIN
*Join this server* (`join_realm`): connect and verify the Host → `welcome` → (existing-only realm and no credentials: ask for the one-time link) → get or reuse the account → if a character was chosen: the existing Phase 7/8
compatibility check against the capabilities in the Registry record (nothing is written when it is incompatible) → the realm's route → client check → write the realmlist (the existing Phase 9 machinery with backups) and start the game.
Outcomes that are **not failures but honest states**: `needs_link`, `needs_client` (no game client set up), `incompatible` (the verdict and its notes), `needs_transfer` (a character that did not come from this realm cannot be put on it
yet), and **`needs_relay`: "This server requires Relay support, which is not available yet."** when the owner has not stated an address the game client can reach (*Settings → Public listing → Address players connect to*, a host and optional port; it is data the
owner supplies, not discovery, NAT traversal or a relay). Connectivity is never faked.
The player is shown the account name and can **copy the name and the password** (the password is read from the protected store only when asked, shown only on request, never logged).
## 10. Native login prefill: audit and blocker
The goal was to start the Ascension client with the name and password already in its login window, *if* the client's "Remember" persistence can be reproduced safely (no process injection, no `WriteProcessMemory`, no input injection,
backup and recovery of every client file touched, passwords never logged). **It cannot, with what is known, and the Manager does not attempt it.**
What was found: the client has native login-window storage (Lua `GetLastAccount`/`SaveAccount`-style calls in its glue code); the remembered login is not in `WTF/Config.wtf` or `realmlist.wtf` but, as far as can be told,
in `WTF/Custom/GlueConfig.json`, a 180-byte file of printable characters with 5.75 bits/byte of entropy that is not base64, hex or any text scheme tried, contains none of the known plaintext of a login (account name,
password, realmlist address), and is **rewritten by the client every time it starts, before any login** (observed during the gate: starting the game from the Manager changed it without changing its size). So the file holds more than the login, in an unknown
obfuscated format. Writing it blindly could corrupt the client's glue configuration or lock the account out of "Remember"; guessing the scheme from outside the client is reverse engineering that the control plane does not need.
The controlled before/after diff that would settle it needs a human to log in with "Remember" ticked (driving the game's window from outside is exactly the input injection that is out of scope); `tools/audit-client-credentials.ps1` is the procedure and
tool for that: it snapshots the client's `WTF`/`Data` files, and after a disposable login reports which files changed and, as booleans only, whether the test name or password appears in them as text, UTF-16, hex or base64. **Never print the contents.**
What is delivered instead: the account and its password in the protected store, *Copy name* / *Copy password* / *Show password* in the join panel (the game's own login window is pasted into), and the realmlist written and the game started.
**Blocker before this can change:** the owner's run of the audit with a disposable account, and then a decision whether the format is understood well enough to write safely.
## 11. The Phase-12 gate
See section 13 of this document for results (run 2026-10-07/08 with a Host Manager on the real smoke realm *PT Guest* (a real worldserver, MySQL and RA), three Player Managers with their own data folders and DPAPI stores, and the Coordinator on the VPS).
## 12. Threat notes
| Threat | Answer |
|---|---|
| A compromised or curious Coordinator | reads nothing (Noise AEAD), changes nothing (authenticated), cannot impersonate a Host (needs the realm key's signature over the channel's own hash) or a Player (needs the player key); it can refuse, delay, cut and count. It stores nothing |
| A man in the middle between Player and Host | two handshakes means two different `h`; the Host proof covers only the real one, so the Player refuses and sends nothing |
| A Registry that names a wrong key for a realm | refused after the first visit (pinned); before it, the Registry is the trust anchor of "this id has this key" (the same anchor as for publishing) |
| Replay of a hello | the Coordinator's per-connection nonce and a 120 s window; the Noise handshake is fresh per channel |
| A player using another player's id | the Host binds id → key at first use; the proof is a signature over the channel hash, so a recorded proof is useless |
| A player claiming a character that is not theirs, or one already claimed, or one that is in the world | refused (section 7), including when another PlayerIdentity knows the account's name and password (the account is bound) |
| Guessing an account's password through *link* | 5 failures per 10 minutes per player id, 30 per realm; the Coordinator limits channels per address and per realm |
| A hostile or broken Host | sees only what a Host must (section 1); a character payload is verified (hash, format, id) before it is stored; one bad Host cannot reach another realm's credentials (they are per realm) |
| A flood | per-address and per-realm channel limits, a byte budget, lifetimes, frame and request size limits, strict JSON; the Host handles requests one at a time and caps a channel at 60 requests |
| Local theft of the player's files | the identity key and passwords are DPAPI-protected on Windows; on other systems only file permissions (stated) |
## 13. What remains before Relay
1. **TLS** on the node (needs a domain; no custom PKI). Until then the WebSocket is plain, which is safe for what it carries (everything private is inside the Noise channel) but exposes metadata to an on-path observer.
2. **A game route**: Relay or a direct route. Today only an owner-stated address makes `Join` start the game.
3. **Moving a portable character onto a realm that is not its home** (import, update, session prepare over the channel): the Host side exists in the engine (Phases 7–9) but the messages to carry an offer and its answers between two Managers are not defined; `needs_transfer` says so.
4. **Native login prefill**: the owner's audit run (section 10).
5. The Host's realm key into the `SecretStore` (section 8), and a keyring store on Linux/macOS.
6. A real login at the character-select screen is not driven by the Manager (by design); the gate set `account.online` the way the worldserver does (see the results).
+52 -5
View File
@@ -1,6 +1,6 @@
# Registry deployment (Phase 10)
# Registry and Coordinator deployment (Phases 10, 10.1, 11, 12)
How the Registry runs on `coa-infra-01`, how to rebuild or restart it, and what the Phase-10 gate checked. Protocol: [REGISTRY_PROTOCOL.md](REGISTRY_PROTOCOL.md).
How the Registry and the Coordinator run on `coa-infra-01`, how to rebuild or restart them, and what the gates checked. Protocols: [REGISTRY_PROTOCOL.md](REGISTRY_PROTOCOL.md), [CONTROL_PROTOCOL.md](CONTROL_PROTOCOL.md).
Node facts: [INFRASTRUCTURE.md](INFRASTRUCTURE.md), [VPS_BASELINE.md](VPS_BASELINE.md). `<VPS_IP>` is the node's public IPv4 (kept out of git); no secret is in this file.
## Topology
@@ -10,6 +10,7 @@ Internet
| 22/tcp (sshd) 80/tcp, 443/tcp (Caddy, the only container with published ports)
v
Caddy :80 /registry/* -> coa-registry:8080 network coa-ingress
:80 /coord/* -> coa-coordinator:8081 network coa-ingress (WebSocket; Phase 12)
|
Registry container (coa-registry) +--- network coa-db (internal) ---+
v
@@ -20,7 +21,7 @@ Registry container (coa-registry) +--- network coa-db (internal) ---+
Never add `5432:5432` or `8080:8080`.
* The Registry joins `coa-ingress` (to be reached by Caddy) and `coa-db` (to reach PostgreSQL). PostgreSQL joins only `coa-db`, which is `--internal`.
* Caddy serves plain HTTP on :80 (no domain yet: `auto_https off`, admin API off). Port 443 is allowed by UFW and published by Docker, but nothing listens on it until TLS exists.
**HTTPS is mandatory before Phase 11 is treated as release-ready**; for this staging gate HTTP is acceptable because every Host request is signed and carries no secret.
**HTTPS is mandatory before the list and the control plane are shown to players outside the test group** (the one open item of Phase 11 and of Phase 12's transport); for these staging gates HTTP is acceptable because every Host request is signed, the public list is public, and everything private on the control plane is end-to-end encrypted inside the WebSocket (an on-path observer of plain HTTP learns that a connection to a realm exists and its size and timing, nothing else). No custom PKI is used or planned: TLS needs a domain name and a normal certificate (ACME through Caddy).
## Files (in git: `services/coa-registry/deploy/`)
@@ -28,7 +29,11 @@ Registry container (coa-registry) +--- network coa-db (internal) ---+
|---|---|
| `postgres.compose.yaml` | `/opt/coa/postgres/compose.yaml` |
| `registry.compose.yaml` | `/opt/coa/registry/compose.yaml` (+ `.env`: `REGISTRY_TAG`, rate limits) |
| `Caddyfile` | `/opt/coa/caddy/Caddyfile` (the Phase -1 file is kept as `Caddyfile.phase-1.bak`) |
| `Caddyfile` | `/opt/coa/caddy/Caddyfile` (the Phase -1 file is kept as `Caddyfile.phase-1.bak`, the Phase-11 one as `.phase-12.bak`) |
| `../../coa-coordinator/deploy/coordinator.compose.yaml` | `/opt/coa/coordinator/compose.yaml` (+ `.env`: `COORDINATOR_TAG`) |
| `../../coa-coordinator/deploy/provision-db.sh` | creates `coa_coordinator`, a role that may read four columns of `realms` (`realm_id`, `public_key`, `published`, `advert_version`) and nothing else; also creates its password file |
| `../../coa-coordinator/deploy/deploy.sh` | builds the Coordinator image on the node and starts it |
| `../../coa-coordinator/Dockerfile` | same shape as the Registry's (non-root uid 10002, read-only root, all capabilities dropped, no-new-privileges, 256 MB, 128 pids) |
| `provision-db.sh` | run once (idempotent) as root: creates the least-privilege role `coa_registry` and database `coa_registry` |
| `deploy.sh` | builds the image on the node from the working tree and restarts the stack |
| `audit-db.sql` / `audit-db.sh` | what the database holds (gate item 17) |
@@ -40,6 +45,7 @@ Registry container (coa-registry) +--- network coa-db (internal) ---+
```
/opt/coa/secrets/postgres_password root:root 0600 superuser of PostgreSQL; read only by the PostgreSQL entrypoint
/opt/coa/secrets/registry_db_password root:10001 0440 password of the role coa_registry; mounted at /run/secrets in the Registry container
/opt/coa/secrets/coordinator_db_password root:10002 0440 password of the role coa_coordinator (read-only, four columns); mounted in the Coordinator container
```
Both were generated on the node (`/dev/urandom`, 40 characters) and never displayed. The Registry connects as `coa_registry` (not a superuser, no CREATEDB/CREATEROLE,
@@ -69,7 +75,8 @@ Staging note: the gate ran with `REGISTRY_REGISTER_BURST=100`; the production de
```bash
cd /opt/coa/registry && sudo docker compose ps && sudo docker compose logs --tail 50 # JSON lines
curl -s http://<VPS_IP>/registry/v1/healthz # {"status":"ok","protocol_version":1}
curl -s http://<VPS_IP>/registry/v2/healthz # {"status":"ok","protocol_version":2}
curl -s http://<VPS_IP>/coord/v1/health # {"hosts":N,"protocol_version":1,"status":"ok"}
sudo sh /opt/coa/registry/src/services/coa-registry/deploy/audit-db.sh # contents audit
```
@@ -113,3 +120,43 @@ After the logout the final sync was made and the next session armed as usual.
**Found by running it for real** (and fixed): on Windows the key file's access list had to include delete rights or the final rename was refused (`icacls … (F)`); the realm probe, which takes many seconds, was blocking the publishing loop (now a cached, background probe);
a republish within the same second as the last request was refused as a replay (the counter is carried over).
## The Phase-10.1 gate (protocol 2)
Run on 2026-10-07 against the same node. Tools: `services/coa-registry-e2e/tests/live_vps.rs` (`gate_1_to_12_against_the_node` re-run for protocol 2, `gate_v1_record_republishes_under_v2`), `deploy/gate-restarts.sh`, `deploy/audit-db.sh`, and the real Manager window.
| Item | Result |
|---|---|
| A realm republishes under protocol 2 | pass: the real *PT Guest* realm of the smoke setup registered with its structured listing (rates `Rate.*` read from its `worldserver.conf`, modules from the catalog, level cap 60 from the running core, population from its database: 0 players + 12 bots counted separately, capacity none because `PlayerLimit = 0`) |
| A record written under protocol 1 is migrated, not lost | pass (`gate_v1_record_republishes_under_v2`): the Phase-10 probe realm kept its `RealmId`, key and `created_at`; it was invisible to the public list until it registered again under protocol 2 with the same key, which was one metadata revision (1 → 2) |
| Records survive restarts | pass (`gate-restarts.sh` re-run for protocol 2: Registry restart, PostgreSQL restart, both stopped and started in the wrong order, `compose down/up`: after each, the realm and its key are intact; a Registry restart while Players were joining did not disturb them) |
| Protocol 1 is refused by one documented policy | pass: `/registry/v1/*` → `400 unsupported_protocol_version` naming protocol 2 (REGISTRY_PROTOCOL.md section 0); `X-Coa-Registry-Version: 1` on a v2 path → the same code |
| Human and bot counts are separate | pass (two columns, two fields; the list's "N players + M bots") |
| Capabilities are hash-checked; so is the listing | pass: a hash that is not the hash of the content → `400 invalid_metadata`; a heartbeat with another hash and no part → `resend_*` and nothing applied |
| The Phase-10 security gates still pass | pass: `gate_1_to_12_against_the_node` re-run today, 12 items, 45 s |
## The Phase-11 gate (public list)
Run today with 5000 synthetic realms loaded by `deploy/synthetic.sh load 5000` (marked, removed afterwards; the table is back to the one real realm), through the public path (workstation → Internet → Caddy → Registry → PostgreSQL):
| Item | Result |
|---|---|
| Paging through thousands of realms | pass: for each of 4 sorts (players, name, cap, created) 5001 realms in 51 pages, no repeat, none skipped, in the order the sort promises (`gate11_the_public_list_pages_through_thousands_of_realms`); 204 page requests, median 107 ms, p95 112 ms, worst 205 ms (the time is the round trip from here, not the query) |
| The query plan stays an index scan | pass (`synthetic.sh explain`): `realms_browse_name`, `_players`, `_cap`, `_created`, 0.25–0.76 ms for a page of 51 with 5000 rows |
| Filters, bounds, ETag/cache, read-only | pass (`gate11_filters_etag_and_read_only`): search, mode, cap range, module, online players; limits above 100, a repeated or unknown parameter and a foreign cursor → 400; `If-None-Match` → 304; `POST`/`PUT`/`DELETE` on the public paths → 405 |
| The Manager's *Servers* page | pass in the real window against the live Registry: the table, filters, sort headers, module chips and tooltips from the local catalog (an unknown id as plain text), the detail drawer with the compatibility verdict of the selected portable character, "—" for ping |
| Configurable Registry address, none hard-coded | pass (setting + `COA_REGISTRY_URL`) |
| **TLS** | **not done: the node has no domain.** This is the only gate item that cannot be passed without something the project does not have (a domain name). Nothing was invented instead (no self-signed or private CA) |
## The Coordinator (Phase 12)
`services/coa-coordinator` (own Cargo workspace, like the Registry; `crates/coa-control-proto` is the shared protocol). One container, no published port, Caddy proxies `/coord/*`
(the health check is `/coord/v1/health`). It holds **nothing on disk and writes nothing to any database**: the connected Hosts are an in-memory table; it reads the realm's public key
from the Registry's database as `coa_coordinator` (`SELECT (realm_id, public_key, published, advert_version) ON realms`, `default_transaction_read_only`, 4 connections), only to check a Host's proof.
Limits (defaults of `coa_control_proto::coord::limits`, enforced by the service and tested): 16 simultaneous Player channels per Host, 8 connections per source address, 60 new Player channels per minute per realm and 30 per
address, 16 MiB through one channel, a channel lives at most 600 s and at most 60 s silent, the Host has 10 s to answer a new channel, 20 KiB per forwarded frame, 10 s to say hello, a hello's timestamp within 120 s.
Operating: `VPS=ubuntu@<VPS_IP> TAG=<tag> sh services/coa-coordinator/deploy/deploy.sh` (provisions the role, builds on the node, starts); `sudo docker logs coa-coordinator-coordinator-1` (JSON lines: realm id, channel number, player id, address, byte count, seconds — never a name, a password or a payload; the gate greps for them).
A restart of the Coordinator drops the Host links; the Hosts reconnect on their own (checked: 5 s, backoff 2 s → 60 s) and joining works again at once. A restart of the Registry changes nothing for established links.
The Phase-12 gate results are in [CONTROL_PROTOCOL.md](CONTROL_PROTOCOL.md) section 11.
+148 -70
View File
@@ -1,21 +1,35 @@
# Registry protocol 1 (Phase 10)
# Registry protocol 2 (Phase 10.1, public list in Phase 11)
The central realm Registry is **discovery and presence infrastructure only**. It answers two questions: *which realms exist and who publishes them*, and
The central realm Registry is **discovery and presence infrastructure only**. It answers: *which realms exist and who publishes them*, *what do they offer* and
*are they alive*. Code: `crates/coa-registry-proto` (the protocol, shared by both sides), `services/coa-registry` (the service),
`crates/coa-core/src/realm_registry` (the Host Manager's client and publishing loop).
`crates/coa-core/src/realm_registry` (the Host Manager's client and publishing loop, and the player's read-only browse client).
`REGISTRY_PROTOCOL_VERSION = 1`. It is the Registry's own number; it has nothing to do with the portable character, session or capability-profile versions.
`REGISTRY_PROTOCOL_VERSION = 2`. It is the Registry's own number; it has nothing to do with the portable character, session or capability-profile versions, nor with
the control protocol of `docs/CONTROL_PROTOCOL.md`.
## 0. Protocol 1 and what happened to it (the one documented policy)
Protocol 1 (Phase 10) carried a flat description. Protocol 2 replaces it with a structured listing. The policy, applied by code and checked by the live gate
(`gate_v1_record_republishes_under_v2`):
* **Protocol 1 requests are refused explicitly**: anything under `/registry/v1/` answers `400 unsupported_protocol_version` ("protocol version 1 is no longer supported; this Registry speaks
protocol 2 under /registry/v2"). Nothing is accepted silently and nothing is converted on the fly. A Host Manager that speaks protocol 1 is told so and stops publishing.
* **Records are migrated forward only** (`migrations/0002_protocol_v2.sql`): a realm registered under protocol 1 keeps its `RealmId`, its public key and its creation time. It is marked
`advert_version = 1`, is **not shown in any public list** and is not offered for hosting, until its Host republishes under protocol 2 (a `register` with the same key, which is
the normal start-up of a Manager); the record is then an `advert_version = 2` record with the same identity.
* There is no downgrade path and no dual-protocol service.
## 1. Trust model
The Registry never receives or stores: MySQL credentials, RA credentials, game account passwords, `PortableCharacter` snapshots, canonical revisions or
payloads, inventories, portable session payloads, player character ids. There is no field for any of them; every request type refuses unknown fields,
so nothing of the sort can be smuggled in, and the database has no table or column that could hold it (`deploy/audit-db.sql`).
so nothing of the sort can be smuggled in, and the database has no table or column that could hold it (`deploy/audit-db.sql`). Phase 12 adds nothing to this: the control plane
(`docs/CONTROL_PROTOCOL.md`) lives in a separate service and reads exactly one column of this database that is not public anyway (the realm's public key).
Registering is not trust. The capabilities a Registry publishes are **host-advertised discovery metadata**: a later JOIN negotiates capabilities again with the
live Host. The Registry checks that an advertisement is well-formed, bounded and self-consistent (its hash is the hash of its content), not that it is true.
Registering is not trust. What a Registry publishes is **host-advertised discovery metadata**: a later JOIN negotiates capabilities again with the live Host. The Registry checks that
an advertisement is well-formed, bounded and self-consistent (its hashes are the hashes of its content), not that it is true.
There is no shared admin secret, no bearer token, no unauthenticated admin API, and no public realm list yet.
There is no shared admin secret, no bearer token and no unauthenticated admin API. The public read API (section 5) exposes only what a realm chose to publish.
## 2. Identity
@@ -23,18 +37,19 @@ Every publishable realm has an identity independent of its address: `RealmId` (a
has one spelling in the signed input) and an Ed25519 key pair, **one per realm** (not per Manager installation). The first registration carries the public key
and proves possession by signing the request. Afterwards the Registry holds the key for that id: a request for the id signed by any other key is refused
(`401 invalid_signature`), and a registration for the id with another key is refused (`403 realm_key_mismatch`). Nobody can take an id over.
The same key is what the Host proves to the Coordinator and to players (`docs/CONTROL_PROTOCOL.md`), which is how "this Host is the realm in the list" is established.
The private key stays in the Host Manager's storage (section 8) and never enters a log, a diagnostic, a descriptor or the Registry.
The private key stays in the Host Manager's storage (section 9) and never enters a log, a diagnostic, a descriptor or the Registry.
## 3. Signed requests
Every mutating request (and the authenticated read) is signed.
Every mutating request (and the authenticated self read) is signed.
Headers:
| Header | Value |
|---|---|
| `X-Coa-Registry-Version` | `1` |
| `X-Coa-Registry-Version` | `2` |
| `X-Coa-Realm` | the realm id; for every endpoint except `register` it must equal the id in the path |
| `X-Coa-Timestamp` | unix seconds (decimal) |
| `X-Coa-Signature` | base64url (no padding, 86 characters) of the 64-byte Ed25519 signature |
@@ -42,7 +57,7 @@ Headers:
The signed input is these seven lines, joined by a single `\n` (no trailing newline), as UTF-8 bytes:
```
coa-registry-sig-v1
coa-registry-sig-v2
<protocol version, decimal>
<HTTP method, upper case>
<request path exactly as sent; no query string is allowed>
@@ -51,17 +66,18 @@ coa-registry-sig-v1
<SHA-256 of the body bytes as sent, lower-case hex> (the empty body for a GET)
```
The signature is made over those bytes with the realm's Ed25519 key (RFC 8032, deterministic). Verification is strict (`verify_strict`).
The signature is made over those bytes with the realm's Ed25519 key (RFC 8032, deterministic). Verification is strict (`verify_strict`). The domain line changed from `-v1` to `-v2`, so a
signature made for protocol 1 can never be valid for protocol 2.
### Rules checked, in this order
1. `429 rate_limited`: the source address is over its allowance (section 6).
1. `429 rate_limited`: the source address is over its allowance (section 7).
2. `413 request_too_large`: more than 65536 body bytes.
3. `400 malformed_request`: a header is missing or malformed; the path has a query string; the body is not the expected JSON, or has an unknown field.
4. `400 unsupported_protocol_version`: the header, or `protocol_version` in the body, is not `1`.
4. `400 unsupported_protocol_version`: the header, or `protocol_version` in the body, is not `2`.
5. `400 invalid_realm_id`: not a canonical UUIDv7, or the header and the path (or body) name different realms.
6. `401 bad_timestamp`: the timestamp is more than 300 s from the Registry's clock (either way).
7. For `register`: `400 invalid_metadata` (a limit or consistency rule below), then the signature is verified with the key *in the body*.
7. For `register`: `400 invalid_metadata` (a limit or consistency rule of section 6), then the signature is verified with the key *in the body*.
For the others: `404 unknown_realm`, then the signature is verified with the *stored* key.
8. `401 invalid_signature`: the signature does not match (a forged request also costs its sender more of its address allowance).
9. `403 realm_key_mismatch`: `register` for a known id with another key (cannot happen with a valid signature from the stored key).
@@ -74,116 +90,178 @@ The signature is made over those bytes with the realm's Ed25519 key (RFC 8032, d
Key: the Ed25519 seed is 32 bytes of `0x07`; public key (base64url) `6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw`.
Realm `018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d`.
Heartbeat, body `{"protocol_version":1}` (SHA-256 `00aa4c2c857995eb8e19cb0fade07e4b49aac774e37a99c37a0c9f549204d9de`), timestamp `1790000000`:
Heartbeat, body `{"protocol_version":2}` (SHA-256 `e68cb099ab8a3557572bff7e669270ac75fe321c1920b12a2aa7a535d1d16155`), timestamp `1790000000`:
```
coa-registry-sig-v1
1
coa-registry-sig-v2
2
POST
/registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d/heartbeat
/registry/v2/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d/heartbeat
018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d
1790000000
00aa4c2c857995eb8e19cb0fade07e4b49aac774e37a99c37a0c9f549204d9de
e68cb099ab8a3557572bff7e669270ac75fe321c1920b12a2aa7a535d1d16155
```
signature `1ys2C7RVjhEtTl0bVrdowpuPELwOnhuiYZKp88dAWdcA3cezP9QuI903OJi2N2mFA9kifblD_fvdT2B_8F5_Dw`.
signature `MjXmYOIM1Bap5C5XNPspElOTNIAnKBN4pTfvhF6r29zq7A0elzXi7Qk8D3FyfL1CtVmpliUAr3MY1TGrGPyqAQ`.
Authenticated read, `GET /registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d`, empty body (SHA-256 `e3b0c442…b855`), timestamp `1790000030`:
signature `38nHMS6yvl8yrTbLmG1w5LiLVkvTaiRdQPrgT2GSvtR8jW_W0z50IRxD_kInBYP5Yl1Qwwm-r6BVDdTR5ts-BQ`.
Authenticated self read, `GET /registry/v2/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d/self`, empty body (SHA-256 `e3b0c442…b855`), timestamp `1790000030`:
signature `yqd_TFTooZcWb0GhLw3Tl9FwnBWCHo3Q2AYpoNn4CCJljENOgW9WaNlODoInEVglrCNKJmoYuahPyZfcEJk_AA`.
Both are checked by `sign::tests` and independently by `tools/registry-vector-check.py` (RFC 8032 reference algorithm in plain Python; it prints the public key and the heartbeat signature).
Both are checked by `sign::tests` and independently by `tools/registry-vector-check.py` (RFC 8032 reference algorithm in plain Python).
## 4. Endpoints
| | |
|---|---|
| `GET /registry/v1/healthz` | unauthenticated: `{"status":"ok","protocol_version":1}`, `503 degraded` when the database is unreachable |
| `POST /registry/v1/realms/register` | create the realm's record, or announce the same key again; publishes |
| `POST /registry/v1/realms/{realm_id}/heartbeat` | presence, player count, and metadata that changed |
| `POST /registry/v1/realms/{realm_id}/unpublish` | stop publishing (the record stays) |
| `GET /registry/v1/realms/{realm_id}` | the authenticated self read (integration tests, support); **not** a public list |
| `GET /registry/v2/healthz` | unauthenticated: `{"status":"ok","protocol_version":2}`, `503 degraded` when the database is unreachable |
| `POST /registry/v2/realms/register` | create the realm's record, or announce the same key again; publishes |
| `POST /registry/v2/realms/{realm_id}/heartbeat` | presence, population, and the listing / capabilities when they changed |
| `POST /registry/v2/realms/{realm_id}/unpublish` | stop publishing (the record stays) |
| `GET /registry/v2/realms/{realm_id}/self` | the authenticated self read (works while unpublished; integration tests, support) |
| `GET /registry/v2/realms` | **public**, read-only: a page of the realm list (section 5) |
| `GET /registry/v2/realms/{realm_id}` | **public**, read-only: one published realm in full (section 5) |
| `/registry/v1/*` | refused as described in section 0 |
Anything else is `404`. There is no realm list, no search and no admin endpoint.
Anything else is `404`. There is no search endpoint other than the list's `q`, no write on the public side, and no admin endpoint.
Errors are `{"error":{"code":"<code>","message":"…"}}` with the codes above plus `unknown_realm` (404), `not_published` (409), `unavailable` (503, storage), `internal` (500).
Codes a repeat cannot fix are *permanent* (`malformed_request`, `unsupported_protocol_version`, `request_too_large`, `invalid_metadata`, `invalid_realm_id`, `invalid_signature`,
`realm_key_mismatch`): the Host stops instead of retrying.
### The three parts of an advertisement
| Part | Hash | What it is |
|---|---|---|
| **Listing** | `listing_hash` = SHA-256 of `coa-registry-listing-v2\0` ‖ the JSON of the listing in its fixed field order | what a player reads: `display_name`, `description`, `language`, `region` (optional), `rates`, `modules`, `account_provisioning`, `manager_version` |
| **Capabilities** | `capabilities_hash` = `advert_hash` (SHA-256 of `coa-registry-advert-v1\0` ‖ content hash ‖ progression numbers and signature) | the real core's capability profile (profile version 2), what the compatibility check uses; the ruleset and the level cap are *derived* from it, never claimed separately |
| **Population** | none (changes every heartbeat) | `players` (human characters online), `bots` (companion characters online, counted separately), `capacity` (the worldserver's `PlayerLimit` when above 0, else absent) |
Every number comes from the real server, and what cannot be known is absent/`null`, never a guess: rates from the realm's effective `worldserver.conf` (`Rate.XP.Kill`, `Rate.XP.Quest`,
`Rate.XP.Explore`, `Rate.Drop.Item.Normal`, `Rate.Drop.Money`, `Rate.Reputation.Gain`, `Rate.Honor`), modules from the Manager's module catalog against the server's installed and enabled modules
(`{id, version?, enabled}`; no descriptions are sent, a player's Manager brings its own catalog), population from the realm's database. There is **no `game_mode` field and no ping field**:
a ping needs a game route, which does not exist yet.
### Register
```json
{ "protocol_version": 1, "realm_id": "…", "public_key": "<base64url, 43 chars>",
"display_name": "…", "description": "…", "language": "en", "ruleset": "coa",
"manager_version": "0.6.6",
"capabilities": { …RealmCapabilities profile version 2… },
"capabilities_hash": "<64 hex>", "player_count": 3, "player_capacity": 100 }
{ "protocol_version": 2, "realm_id": "…", "public_key": "<base64url, 43 chars>",
"listing": { "display_name": "…", "description": "…", "language": "en", "region": "EU",
"rates": { "xp_kill": 1.0, "xp_quest": 1.0, "xp_explore": 1.0, "loot": 1.0, "money": 1.0, "reputation": 1.0, "honor": 1.0 },
"modules": [ { "id": "mod-ah-bot", "version": "1.2.0", "enabled": true } ],
"account_provisioning": { "automatic": true, "existing_only": false },
"manager_version": "0.6.6" },
"listing_hash": "<64 hex>",
"capabilities": { …RealmCapabilities profile version 2… }, "capabilities_hash": "<64 hex>",
"population": { "players": 3, "bots": 12, "capacity": 100 } }
```
Response: `created`, `metadata_revision`, `published`, `capabilities_hash`, `server_time`, `heartbeat_interval_secs` (30), `online_ttl_secs` (120).
Registering a known id with the same key is idempotent and is how a restarted Manager (or a republication after `unpublish`) announces itself.
Response: `created`, `metadata_revision`, `published`, `listing_hash`, `capabilities_hash`, `server_time`, `heartbeat_interval_secs` (30), `online_ttl_secs` (120).
Registering a known id with the same key is idempotent and is how a restarted Manager (or a republication after `unpublish`, or after protocol 1) announces itself.
### Heartbeat
`capabilities_hash` is always sent; `capabilities` only when it differs from the hash the Registry last acknowledged. Optional: `manager_version`, `player_count`,
`player_capacity` (absent = unknown), `display_name`/`description`/`language` (absent = unchanged). If the hash differs and the capabilities were not sent, the
answer carries `resend_capabilities: true` and nothing is applied; the Host sends them with the next heartbeat. An unchanged heartbeat writes only
`last_seen_at`, the player numbers and the replay counter; the capabilities JSON is rewritten only when its hash changed. A heartbeat of an unpublished realm is
`409 not_published`; the Host registers again to publish.
`listing_hash`, `capabilities_hash` and `population` are always sent; `listing` and `capabilities` only when their hash differs from the one the Registry last acknowledged. If a hash differs and
its part was not sent, the answer carries `resend_listing` / `resend_capabilities: true` and nothing of it is applied; the Host sends the part with the next heartbeat. An unchanged heartbeat
writes only `last_seen_at`, the population and the replay counter. A heartbeat of an unpublished realm is `409 not_published`; the Host registers again to publish.
### Metadata revision
`metadata_revision` starts at 1 and grows by one whenever the display name, description, language, ruleset, Manager version, capabilities, or the published state
changes; presence and player counts do not move it. The Registry returns it in every answer so that an update is explicit.
`metadata_revision` starts at 1 and grows by one whenever the listing, the capabilities or the published state changes; presence and population do not move it.
## 5. Limits
## 5. The public read API (Phase 11)
`GET /registry/v2/realms` returns `{ "protocol_version": 2, "realms": [RealmSummary…], "next_cursor": "…"|null, "server_time": … }`. **Bounded by construction**:
| Parameter | Rule |
|---|---|
| `limit` | 1..100, default 50 |
| `cursor` | opaque keyset cursor from the previous page (≤ 300 bytes); it binds the sort, the order and the filters that made it, and a cursor used with other ones is refused. No offsets, no totals |
| `q` | substring of the display name, ≤ 64 characters, case-insensitive (`strpos`, so `%` and `_` are ordinary characters) |
| `ruleset` | `coa` or `wildcard` |
| `cap_min`, `cap_max` | level cap range, 0..255 (realms that did not report a cap match only when no cap filter is set) |
| `module` | a module id that is present **and enabled** |
| `players_min` | at least this many human players online |
| `language`, `region` | exact tags |
| `status` | `online` (default), `offline`, `all`; online = published and heard of within the TTL |
| `sort`, `order` | `name` (asc by default), `players`, `cap`, `created` (desc by default), `asc`/`desc`; every sort is totalled by `realm_id`, so pages never repeat or skip a realm |
Any other parameter, a repeated one or a query string over 1024 bytes is `400`. Only published, protocol-2 realms are ever listed. `RealmSummary` carries what the table shows (name, a description
cut to 160 characters, language, region, ruleset and level cap derived from the capabilities, rates, modules, population, account provisioning, `capabilities_hash`, `online`, `last_seen_at`);
`GET /registry/v2/realms/{id}` adds the full description, the public key (so that a player's Manager can check the Host against it), the full capabilities and the revision.
An unpublished or unknown realm is `404`.
Answers carry `Cache-Control: public, max-age=5` and a strong `ETag` (a conditional request with a matching `If-None-Match` is `304`); the service also keeps a built answer for 5 s so that many
clients opening the list do not each reach the database. The list is served from partial indexes that match every sort (`migrations/0003`), so a page is an index range scan with a `LIMIT`
even with tens of thousands of realms (checked live with 5000 synthetic realms; see `docs/REGISTRY_DEPLOYMENT.md`). Each source address has its own allowance for reads (burst 60, 2 per second).
## 6. Limits
| What | Limit |
|---|---|
| request body | 65536 bytes |
| display name | 1..80 characters, no control characters, no direction overrides (U+202A–202E, U+2066–2069, U+200E/F), no surrounding space |
| description | 1024 bytes (UTF-8); control characters other than `\n` refused; plain text, never HTML |
| language | a short tag (`en`, `pt-BR`), ≤ 16 bytes |
| language / region | short tags (`en`, `pt-BR`; `EU`), ≤ 16 bytes, letters digits and `-` |
| Manager version | ≤ 32 bytes of `[A-Za-z0-9.+_-]` |
| player count / capacity | ≤ 100000, count ≤ capacity |
| capabilities | profile version 2 only; field by field (≤ 64 extensions, ≤ 16 collection kinds, ≤ 8 client tables, tokens ≤ 64 characters, hashes 64 hex), unknown fields refused, `content_profile_hash` must equal the hash of the content, `ruleset` must equal the capabilities' ruleset |
| `capabilities_hash` | must equal `advert_hash`: SHA-256 of `coa-registry-advert-v1\0` ‖ content hash ‖ the progression numbers and signature (so a level-cap change moves it) |
| rates | each 0..1000 or absent |
| modules | ≤ 64, each id `[a-z0-9-]` ≤ 64 characters, a plain version ≤ 32 bytes or absent, no duplicates |
| population | each number ≤ 100000, players ≤ capacity |
| account provisioning | at least one of `automatic`, `existing_only` |
| capabilities | profile version 2 only; field by field (≤ 64 extensions, ≤ 16 collection kinds, ≤ 8 client tables, tokens ≤ 64 characters, hashes 64 hex), unknown fields refused, `content_profile_hash` must equal the hash of the content |
The advertised type mirrors `RealmCapabilities` field for field (same names, same order: the content hash is taken over the JSON); `coa-core` tests that a real
profile parses into it and keeps its hash, so the two cannot drift apart unnoticed. Consumers must render names and descriptions as plain text.
The advertised capabilities type mirrors `RealmCapabilities` field for field (same names, same order: the content hash is taken over the JSON); `coa-core` tests that a real
profile parses into it and keeps its hash, so the two cannot drift apart unnoticed. Consumers must render every string as plain text (names, descriptions and module ids are not trusted).
## 6. Presence and abuse controls
## 7. Presence and abuse controls
* Heartbeat every ≈30 s (±10 % jitter); online = published and heard of within **120 s**. Missing heartbeats make a realm offline; they never delete it.
* A Host sends heartbeats only while its realm is running; a stopped realm goes offline after the TTL.
* Per source address: 120 requests/minute (burst 120); registration: burst 10, 10 per hour (production defaults; the staging gate ran with 100). A forged request costs 5.
* Per source address: 120 requests/minute (burst 120); registration: burst 10, 10 per hour (production defaults; the staging gates ran with 100). A forged request costs 5.
The address is the last entry of `X-Forwarded-For`, which Caddy sets to the client address it saw; the Registry publishes no port, so only Caddy can reach it.
* Per realm: heartbeat and unpublish burst 4, one more every 10 s (applied only after the signature was verified, so forged traffic cannot throttle a real realm).
* 64 requests in flight, 256 open connections, 10 s request timeout, database pool of 8, `statement_timeout` 5 s, `lock_timeout` 3 s, a 70 KB body cap at Caddy as well.
## 7. Storage
## 8. Storage
PostgreSQL, forward-only migrations (`migrations/NNNN_name.sql`, recorded in `schema_migrations`; a database that is ahead of the binary is refused). One table:
`realms` (realm id, public key, created/updated/last-seen, published, display name, description, language, ruleset, Manager version, capabilities JSONB and hash,
metadata revision, last request timestamp, player count and capacity). There is no heartbeat history and no table about players, characters, accounts or relays.
PostgreSQL, forward-only migrations (`migrations/NNNN_name.sql`, recorded in `schema_migrations`; a database that is ahead of the binary is refused):
## 8. The Host side
| Migration | |
|---|---|
| `0001_realms` | the `realms` table (identity, key, timestamps, published, metadata, capabilities JSONB and hash, revision, last request timestamp, player numbers) |
| `0002_protocol_v2` | `advert_version`, the structured listing columns (region, rates, modules, account provisioning, players, bots, level cap, …) and `listing_hash`; protocol 1 rows stay with `advert_version = 1` |
| `0003_browse_indexes` | partial indexes (published, protocol 2) on exactly the expressions the list sorts by |
| `0004_created_whole_seconds` | `created_at` is a whole second (a CHECK), so that the `created` cursor is exact |
There is no heartbeat history and no table about players, characters, accounts or relays.
## 9. The Host side
`crates/coa-core/src/realm_registry`:
* **Identity**: created the first time publishing is enabled for a local realm. The key is behind `KeyStore`; the implementation (`FileKeyStore`) keeps
`<data>/registry/keys/<realm id>.key` (base64url of the 32-byte seed), created with its permissions restricted *before* the secret is written: mode 0600 on Unix, and
on Windows the inherited permissions removed and the current user granted full control (`icacls`). **Limitation:** this is a file; it is weaker than DPAPI or the Credential Manager, which
the project does not use yet, and anybody who can read the user's files can read the key. It is never written to the realm descriptor, `registry.json`, a log, the diagnostics or the status shown to the interface. If the key
is missing, publication stops with a message; publishing again makes a *new* identity (it never guesses the old key).
* **Settings**: `<data>/registry/registry.json` (the Registry address, and per local realm: enabled, realm id, name, description, language).
* **Lifecycle** (`RegistryHost`, on its own thread, `RegistryRuntime`): publish → ensure identity → register → heartbeat every ~30 s; changed capabilities or metadata ride the next heartbeat;
a Manager restart resumes with the same id and key (registering again as itself); unpublish is retried in the background until delivered or refused. The loop only *reads* what a realm
advertises (probing is done on a separate thread and cached), so a Registry that is down, slow or hostile cannot touch the worldserver or any portable session; nothing here writes portable state.
on Windows the inherited permissions removed and the current user granted full control (`icacls`). **Limitation:** this is a file; it is weaker than DPAPI, which protects the *player's*
secrets in Phase 12 (`SecretStore`) but not yet the realm key; anybody who can read the user's files can read it. It is never written to the realm descriptor, `registry.json`, a log, the
diagnostics or the status shown to the interface. If the key is missing, publication stops with a message; publishing again makes a *new* identity (it never guesses the old key).
* **Settings**: `<data>/registry/registry.json` (the Registry address, and per local realm: enabled, realm id, name, description, language, region, and the owner's access choices of
`docs/CONTROL_PROTOCOL.md`: *existing accounts only* and a stated route).
* **Lifecycle** (`RegistryHost` on its own thread, `RegistryRuntime`): publish → ensure identity → register → heartbeat every ~30 s; a changed listing or capabilities rides the next heartbeat;
a Manager restart resumes with the same id and key; unpublish is retried in the background until delivered or refused. The loop only *reads* what a realm advertises (probing is done on a
separate thread and cached), so a Registry that is down, slow or hostile cannot touch the worldserver or any portable session.
* **Retry**: transient failures (network, timeout, 5xx, rate limit) back off 5 s → 300 s with ±25 % jitter. `unknown_realm`/`not_published` register again. Clock refusals retry with backoff up to 20 times.
Permanent refusals stop the realm's publication (`rejected`) until the owner presses *Try again* or republishes.
* **Interface**: *Settings → Public listing* of a server (address of the Registry, name, description, language, *Publish*/*Stop publishing*, status).
* **Interface**: *Settings → Public listing* of a server (Registry address, name, description, language, region, *Publish* / *Stop publishing*, status, and the access options).
## 9. Not in this phase
## 10. The player side (Phase 11)
Public realm browser, JOIN, remote account provisioning, portable-character network transport, Relay, direct-connect optimisation, Coordinator, player accounts on the node.
HTTPS is mandatory before Phase 11 is treated as release-ready (the staging node serves plain HTTP: requests are signed and carry no secret).
*Servers* is the first page of Player Mode (and available to a Host): a table of the public list (Server, Modules, Cap, Rates, Mode CoA/Wildcard, Ping, Online "N players + M bots"), with search,
filters (mode, level cap range, module, online players) and sortable columns (name, cap, online); the detail drawer shows the rates, the modules and whether the *selected portable character*
can play there, using the existing Phase 7/8 compatibility logic against the capabilities in the record, and the join panel of `docs/CONTROL_PROTOCOL.md`. Module names and tooltips come from the
**local** module catalog; an id the catalog does not know is shown as plain text. Ping shows "—" with an explanation until a real route exists. The Registry address is a setting (and
`COA_REGISTRY_URL` for tests and deployments); nothing is hard-coded. The browse client sends nothing about the player.
## 11. Not in this document
The control plane (accounts, character claiming, the Coordinator), Relay, direct-connect optimisation, portable-character transfer to another Manager. **HTTPS**: the staging node serves plain HTTP
(no domain, so no certificate); requests are signed and carry no secret, and the public list is public. TLS is a hard requirement before the list is shown to players outside the test
group, and is the one open item of Phase 11 (see `docs/REGISTRY_DEPLOYMENT.md`). No custom PKI is used or planned.
+18
View File
@@ -236,6 +236,10 @@ fn existing_only_realms_ask_for_a_one_time_link() {
assert_eq!(wrong.code(), "wrong_credentials");
assert_eq!(pc.link_existing(&mut ch, &env.realm, "legacy", "Secret12").unwrap(), "LEGACY");
assert_eq!(pc.credentials(&env.realm).unwrap().unwrap().password, "Secret12");
// joining afterwards confirms the same account and keeps it a linked one
let again = pc.ensure_account(&mut ch, &env.realm, None).unwrap();
assert_eq!((again.username.as_str(), again.created, again.password_reset), ("LEGACY", false, false));
assert_eq!(pc.known_account(&env.realm).unwrap().unwrap().1, coa_core::control::store::AccountKind::Linked);
assert!(!env.wire_contains("Secret12") && !env.wire_contains("LEGACY"), "the link travelled encrypted");
// linked once: the host holds the mapping, not the password
let m = env.store.lock().unwrap().host_player("srv-1", &pc.identity.player_id).unwrap().unwrap();
@@ -342,3 +346,17 @@ fn identity_accounts_and_credentials_survive_a_restart() {
let db = std::fs::read(dir.path().join("control.sqlite")).unwrap();
assert!(!db.windows(creds.password.len()).any(|w| w == creds.password.as_bytes()));
}
#[test]
fn a_realms_key_is_pinned_at_the_first_visit() {
let env = Env::new(true);
let dir = tempfile::tempdir().unwrap();
let pc = player(dir.path(), Arc::new(MemoryStore::default()));
pc.connect(&env.target()).unwrap().close();
// a directory (the Registry, or anything on the way to it) that later names another key for the same realm id is refused before anything is sent
let swapped = RealmTarget { key: random_key().verifying_key(), ..env.target() };
let err = pc.connect(&swapped).err().expect("the pinned key differs");
assert_eq!(err.code(), "host_not_verified", "{err}");
// the right key still works
pc.connect(&env.target()).unwrap().close();
}
+292 -4
View File
@@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -132,6 +167,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -186,6 +230,17 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.2"
@@ -197,6 +252,19 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -209,18 +277,46 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
"zeroize",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "coa-control-proto"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"coa-registry-proto",
"ed25519-dalek",
"hex",
"serde",
"serde_json",
"sha2 0.10.9",
"snow",
"thiserror",
"uuid",
]
[[package]]
name = "coa-core"
version = "0.6.6"
dependencies = [
"base64 0.22.1",
"chrono",
"coa-control-proto",
"coa-registry-proto",
"dunce",
"ed25519-dalek",
@@ -239,6 +335,7 @@ dependencies = [
"thiserror",
"tracing",
"tracing-subscriber",
"tungstenite",
"uuid",
"windows-sys 0.60.2",
"zip",
@@ -364,6 +461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -376,6 +474,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "ctutils"
version = "0.4.3"
@@ -412,6 +519,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "deadpool"
version = "0.13.1"
@@ -474,6 +587,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@@ -711,6 +825,18 @@ dependencies = [
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -720,11 +846,21 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1029,6 +1165,15 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -1257,6 +1402,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.81"
@@ -1370,6 +1521,29 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "postgres-protocol"
version = "0.6.12"
@@ -1383,7 +1557,7 @@ dependencies = [
"hmac",
"md-5",
"memchr",
"rand",
"rand 0.10.3",
"sha2 0.11.0",
"stringprep",
]
@@ -1410,6 +1584,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -1428,23 +1611,49 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"chacha20 0.10.2",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -1454,6 +1663,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -1793,6 +2011,22 @@ version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "snow"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85"
dependencies = [
"aes-gcm",
"blake2",
"chacha20poly1305",
"curve25519-dalek",
"rand_core 0.6.4",
"rustc_version",
"sha2 0.10.9",
"subtle",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -2015,7 +2249,7 @@ dependencies = [
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand",
"rand 0.10.3",
"socket2",
"tokio",
"tokio-util",
@@ -2164,6 +2398,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand 0.9.5",
"sha1",
"thiserror",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.1"
@@ -2197,6 +2449,16 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -2209,6 +2471,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -2626,6 +2894,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
+79
View File
@@ -0,0 +1,79 @@
<#
.SYNOPSIS
Controlled before/after audit of what the Ascension game client persists when the player logs in with "Remember" ticked (Phase 12).
.DESCRIPTION
The Manager does not write the client's remembered login today: its format is not understood well enough to do it safely (see docs/CONTROL_PROTOCOL.md, "Native
login prefill"). This script is the audit that has to be done by hand, once, with a disposable account, before that can change. It never prints file contents.
1. Close the game. Run: .\audit-client-credentials.ps1 -Phase before
2. Start the game, log in with a DISPOSABLE account (name and password you pick for this test), tick "Remember", reach character selection, quit the game.
3. Run: $env:COA_AUDIT_USER='<test name>'; $env:COA_AUDIT_PW='<test password>'; .\audit-client-credentials.ps1 -Phase after
4. Repeat steps 1-3 with a different disposable account to see which bytes follow the account, and once more with "Remember" unticked to see what that removes.
The report lists the files that changed (path, size before/after, entropy in bits per byte) and, for each, only booleans: whether the test name or password appears
in the file as plain text, UTF-16 text, hex or base64. A file with high entropy and no match is obfuscated or encrypted; the next step is then to learn the scheme
from the client itself, which is out of scope for the Manager's control plane and must not involve injecting into or patching the running game.
#>
param(
[Parameter(Mandatory = $true)][ValidateSet('before', 'after')][string]$Phase,
[string]$Client = 'C:\games\Ascension',
[string]$State = (Join-Path $env:TEMP 'coa-client-audit.json')
)
$ErrorActionPreference = 'Stop'
function Snapshot([string]$root) {
$out = @{}
foreach ($sub in 'WTF', 'Data') {
$dir = Join-Path $root $sub
if (-not (Test-Path $dir)) { continue }
Get-ChildItem $dir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Length -lt 4MB -and $_.Extension -notmatch '^\.(mpq|dbc|blp|m2|wmo|adt|wdt|skin|anim|bone|phys|wav|mp3|ogg|ttf)$' } | ForEach-Object {
$out[$_.FullName.Substring($root.Length)] = @{ size = $_.Length; sha = (Get-FileHash $_.FullName -Algorithm SHA256).Hash; mtime = $_.LastWriteTimeUtc.ToString('o') }
}
}
foreach ($f in Get-ChildItem $root -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -match '^\.(wtf|json|cfg|ini|txt)$' -and $_.Length -lt 4MB }) {
$out[$f.FullName.Substring($root.Length)] = @{ size = $f.Length; sha = (Get-FileHash $f.FullName -Algorithm SHA256).Hash; mtime = $f.LastWriteTimeUtc.ToString('o') }
}
$out
}
function Entropy([byte[]]$b) {
if ($b.Length -eq 0) { return 0 }
$count = New-Object 'int[]' 256
foreach ($x in $b) { $count[$x]++ }
$h = 0.0
foreach ($c in $count) { if ($c -gt 0) { $p = $c / $b.Length; $h -= $p * [Math]::Log($p, 2) } }
[Math]::Round($h, 2)
}
$latin1 = [Text.Encoding]::GetEncoding(28591)
function Contains([byte[]]$haystack, [string]$needle) {
if ([string]::IsNullOrEmpty($needle)) { return 'n/a' }
$forms = @(
[Text.Encoding]::UTF8.GetBytes($needle), [Text.Encoding]::Unicode.GetBytes($needle),
[Text.Encoding]::ASCII.GetBytes(([BitConverter]::ToString([Text.Encoding]::UTF8.GetBytes($needle)) -replace '-', '').ToLower()),
[Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($needle)).TrimEnd('='))
)
$text = $latin1.GetString($haystack)
foreach ($f in $forms) { if ($text.Contains($latin1.GetString($f))) { return $true } }
$false
}
if ($Phase -eq 'before') {
Snapshot $Client | ConvertTo-Json -Depth 4 | Set-Content -Encoding UTF8 $State
"Snapshot saved ($State). Now log in with the disposable account and quit the game."
return
}
$before = @{}
(Get-Content $State -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $before[$_.Name] = $_.Value }
$after = Snapshot $Client
$user = $env:COA_AUDIT_USER; $pw = $env:COA_AUDIT_PW
$changed = @()
foreach ($k in $after.Keys) { if (-not $before.ContainsKey($k) -or $before[$k].sha -ne $after[$k].sha) { $changed += $k } }
foreach ($k in $before.Keys) { if (-not $after.ContainsKey($k)) { "REMOVED $k" } }
foreach ($k in ($changed | Sort-Object)) {
$bytes = [IO.File]::ReadAllBytes((Join-Path $Client $k))
'{0,-8} {1} size {2} -> {3} entropy {4} bits/byte name-in-file: {5} password-in-file: {6}' -f $(if ($before.ContainsKey($k)) { 'CHANGED' } else { 'NEW' }), $k, $(if ($before.ContainsKey($k)) { $before[$k].size } else { '-' }), $after[$k].size, (Entropy $bytes), (Contains $bytes $user), (Contains $bytes $pw)
}
if (-not $changed) { 'Nothing changed under WTF, Data or the client folder.' }