From 0e099ebb1873189819041d96e4df5c815b9a82c9 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Wed, 30 Sep 2026 10:47:33 +0200 Subject: [PATCH] step --- .dockerignore | 5 + Cargo.lock | 76 ----- Cargo.toml | 5 +- Dockerfile | 27 ++ README.md | 253 ++++++++-------- compose.yaml | 15 + docker-entrypoint.sh | 17 ++ src/cli.rs | 172 ++++++----- src/server.rs | 706 +++++++++++++++++++++++++++++-------------- web/admin.html | 109 +++++++ web/app.css | 630 ++++++++++++++++++++++++++++++++++++++ web/app.js | 399 ++++++++++++++++++++++++ web/user.html | 94 ++++++ web/viewer.html | 44 +++ 14 files changed, 2056 insertions(+), 496 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 docker-entrypoint.sh create mode 100644 web/admin.html create mode 100644 web/app.css create mode 100644 web/app.js create mode 100644 web/user.html create mode 100644 web/viewer.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..704776e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +target +data +example-wasm-agent/target \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ca789fe..c65aceb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,15 +103,6 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - [[package]] name = "asn1-rs" version = "0.7.2" @@ -229,28 +220,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-server" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" -dependencies = [ - "arc-swap", - "bytes", - "either", - "fs-err", - "http", - "http-body", - "hyper", - "hyper-util", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "base64" version = "0.22.1" @@ -963,16 +932,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-err" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" -dependencies = [ - "autocfg", - "tokio", -] - [[package]] name = "fs-set-times" version = "0.20.3" @@ -1124,25 +1083,6 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1233,7 +1173,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -2947,20 +2886,6 @@ dependencies = [ "tungstenite", ] -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -4047,7 +3972,6 @@ name = "wolloseum" version = "0.1.0" dependencies = [ "axum", - "axum-server", "base64 0.22.1", "clap", "extism", diff --git a/Cargo.toml b/Cargo.toml index 1ffbc47..7d1b39f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,15 +5,14 @@ edition = "2024" [dependencies] axum = { version = "0.8", features = ["ws"] } -axum-server = { version = "0.8", default-features = false, features = ["tls-rustls-no-provider"] } base64 = "0.22" clap = { version = "4.6.7", features = ["derive", "env"] } extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] } -rcgen = { version = "0.14", features = ["x509-parser"] } +rcgen = "0.14" reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] } ring = "0.17" -rusqlite = { version = "0.40", features = ["bundled"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rusqlite = { version = "0.40", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cae2af1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM rust:1-bookworm AS builder + +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY web ./web +RUN cargo build --release --bin wolloseum + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends util-linux \ + && rm -rf /var/lib/apt/lists/* \ + && install -d --owner=10001 --group=10001 /var/lib/wolloseum + +COPY --from=builder --chown=10001:10001 /build/target/release/wolloseum /usr/local/bin/wolloseum +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +RUN chmod 755 /usr/local/bin/docker-entrypoint.sh + +ENV WOLLOSEUM_DATA_DIR=/var/lib/wolloseum \ + WOLLOSEUM_BIND=0.0.0.0:8080 + +VOLUME ["/var/lib/wolloseum"] +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["/usr/local/bin/wolloseum", "serve"] \ No newline at end of file diff --git a/README.md b/README.md index da8eb8d..5eb82c3 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,156 @@ # Wolloseum Wolloseum is a deterministic, procedurally generated game server. Players -upload untrusted WASM agents (run in a sandboxed Extism/Wasmtime runtime with -fuel and memory limits) that control spawned agents in a tick-based -simulation. All identity and request authentication is certificate-based -(Ed25519 client certificates signed by a locally issued root CA); there are no -passwords or bearer tokens. +upload untrusted WASM agents that control spawned agents in a tick-based +simulation. Agent code runs in a sandboxed Extism/Wasmtime runtime with fuel +and memory limits. -## Building the server +Identity is based on Ed25519 keypairs. The server stores public keys; private +keys stay with their owners. HTTPS and WSS termination are intended to be +provided by a reverse proxy such as nginx or Caddy. The Wolloseum process +serves HTTP and does not create TLS certificates. + +## Build and Run ```sh cargo build --release -``` - -The binary is produced at `target/release/wolloseum`. - -## Configuration - -The server takes its configuration from CLI flags (each with an environment -variable fallback), defined in [src/cli.rs](src/cli.rs): - -| Flag | Env var | Default | Purpose | -| ------------ | -------------------- | ---------------- | -------------------------------------------------- | -| `--data-dir` | `WOLLOSEUM_DATA_DIR` | `data` | Directory for certificates and the SQLite database | -| `--bind` | `WOLLOSEUM_BIND` | `127.0.0.1:8080` | HTTP listener address | - -On first start the server bootstraps a PKI under `/certificates/`: - -- `root-ca.pem` / `root-ca-key.pem` — self-signed root CA -- `server.pem` / `server-key.pem` — HTTP server certificate (issued for `localhost`) -- `admin-client.pem` / `admin-client-key.pem` — the initial admin client identity - -The SQLite database is created at `/wolloseum.sqlite3` and stores -identities, used request nonces, per-player agent WASM, and the core -simulation WASM module. Private key files and the certificate directory are -written with restrictive (owner-only) permissions on Unix. - -## Running the server - -```sh cargo run --release -- serve --data-dir data --bind 127.0.0.1:8080 ``` -On startup the server prints the admin certificate ID and the paths to the -admin client certificate/key and root CA certificate — you'll need these to -issue authenticated CLI/API requests. +### Docker -## API endpoints - -All `/api/*` endpoints except `/api/status` and `/api/events` require request -signing. Each signed request must include: - -``` -X-Client-Id: -X-Request-Timestamp: -X-Request-Nonce: -X-Request-Signature: -``` - -The signed message is `METHOD\nPATH\nTIMESTAMP\nNONCE\n` followed by the raw -request body bytes. - -| Method | Path | Auth | Description | -| ------ | ------------------- | ------------------- | ----------------------------------------------------------- | -| GET | `/` | none | Player web UI | -| GET | `/admin` | none | Admin web UI | -| GET | `/api/status` | none | Current game running state | -| GET | `/api/events` | none (WebSocket) | Live game/agent/core state change events | -| POST | `/api/create_user` | admin | Create a player identity, returns certificate + private key | -| POST | `/api/delete_user` | admin | Delete a player identity and their uploaded agent | -| POST | `/api/start_game` | admin | Resume the simulation | -| POST | `/api/stop_game` | admin | Pause the simulation | -| POST | `/api/update_agent` | any active identity | Upload/replace the caller's agent WASM module | -| POST | `/api/upload_core` | admin | Upload/replace the core simulation WASM module | - -### `/api/upload_core` - -Replaces the core simulation logic module. The request body is the raw WASM -bytes (not JSON), following the same conventions as `/api/update_agent`: - -- Body limit: 1 MiB (`Content-Length` over the limit is rejected) -- The module is sandboxed-loaded (no WASI imports, bounded memory) to confirm - it is valid before being persisted -- Requires admin authentication (signed request headers as above) -- On success the module is stored in the `core_module` table (single active - row) and a `core_uploaded` event is published over `/api/events` +Build and start the server with Docker Compose: ```sh -curl -k http://127.0.0.1:8080/api/upload_core \ - -X POST \ - --data-binary @core_simulation.wasm \ - -H "x-client-id: $ADMIN_ID" \ - -H "x-request-timestamp: $TS" \ - -H "x-request-nonce: $NONCE" \ - -H "x-request-signature: $SIGNATURE" +docker compose up --build -d +docker compose logs -f wolloseum ``` -In practice, use the CLI (below) rather than constructing signatures by hand. +The HTTP service is published on `127.0.0.1:8080`; configure nginx or Caddy to +proxy HTTP and WebSocket traffic to that address and terminate TLS. Compose +stores the SQLite database and generated administrator keypair in `./data`. +The entrypoint sets the data tree's ownership before starting the server as an +unprivileged user. -## Using the CLI client +Set `WOLLOSEUM_UID` and `WOLLOSEUM_GID` in the environment or a Compose `.env` +file to choose the container process identity. Both default to `1000`. The +ownership of the host `./data` tree is changed to these IDs at startup. -The same binary provides a signing HTTP client: +The first startup logs the administrator identity ID and the key path inside +the container. Copy the key to a secure host location for admin sign-in: + +```sh +docker compose cp wolloseum:/var/lib/wolloseum/keys/admin-private.pem /secure/path/admin-private.pem +chmod 600 /secure/path/admin-private.pem +``` + +Back up the key and database volume. To stop the server while preserving data, +run `docker compose down`. To remove all persistent identity and game data as +well, run `docker compose down --volumes`. + +The server bootstraps an administrator keypair on first start: + +- `data/keys/admin-private.pem` is the owner-only Ed25519 PKCS#8 private key. +- The server prints the corresponding identity ID at startup. +- Keep a secure backup of the private key. The server cannot recover it. + +Existing installations continue to use `data/certificates/admin-client-key.pem` +and the old certificate fingerprint as the admin identity ID when those legacy +files are present. Existing player identities and uploaded data remain in the +SQLite database. HTTPS certificates, if needed, belong to the reverse proxy. + +Configuration flags and environment variables: + +| Flag | Environment variable | Default | Purpose | +| ------------ | -------------------- | ---------------- | ------------------------------------------- | +| `--data-dir` | `WOLLOSEUM_DATA_DIR` | `data` | Keypairs and SQLite database directory | +| `--bind` | `WOLLOSEUM_BIND` | `127.0.0.1:8080` | HTTP listener, usually private to the proxy | + +## Web Interfaces + +- `/` is the player console. Users sign in with their identity key and upload or replace their agent WASM. +- `/admin` is the administrator console. It creates and removes users, pauses or resumes the simulation, and replaces the core WASM module. +- `/viewer` is a public live event viewer. It connects to `/api/events`, tracks event sequence numbers, and restores state from snapshots after reconnecting. + +The admin console generates a fresh Ed25519 keypair when creating a player. +The server receives only the public key. The browser downloads a JSON identity +bundle containing the new identity ID and private key; deliver that bundle to +the player through a secure channel. The private key is not stored by the +server. + +On the admin page, enter the startup identity ID and select +`data/keys/admin-private.pem`. For an existing installation, select the legacy +admin key and use the identity ID printed by the server. + +## Browser Authentication + +1. The browser requests `POST /api/auth/challenge` with `{ "client_id": "..." }`. +2. The server returns a random, short-lived challenge. +3. The browser signs the decoded challenge bytes using Ed25519 and posts the identity ID, challenge, and base64 signature to `POST /api/auth/login`. +4. The server verifies the signature against the stored public key and returns an opaque session token. +5. The browser stores the token and sends it as `Authorization: Bearer ` on protected API requests. + +Challenges expire after five minutes and are single-use. Session tokens expire +seven days after the last authenticated API interaction. Each authenticated +request extends the expiry. Only a SHA-256 hash of each token is persisted. +Revoking an identity by deleting it also removes its sessions. + +## API + +| Method | Path | Access | Purpose | +| ------ | --------------------- | ---------------- | ------------------------------------------------- | +| `POST` | `/api/auth/challenge` | Public | Request an identity challenge | +| `POST` | `/api/auth/login` | Public | Verify a signed challenge and issue a token | +| `GET` | `/api/me` | Session | Read the signed-in identity and agent status | +| `GET` | `/api/users` | Admin session | List identities and agent digests | +| `GET` | `/api/status` | Public | Read the simulation running state | +| `GET` | `/api/events` | Public WebSocket | Receive snapshots and ordered state events | +| `POST` | `/api/create_user` | Admin session | Register a username and base64 Ed25519 public key | +| `POST` | `/api/delete_user` | Admin session | Delete a player and its uploaded agent | +| `POST` | `/api/start_game` | Admin session | Resume the simulation | +| `POST` | `/api/stop_game` | Admin session | Pause the simulation | +| `POST` | `/api/update_agent` | User session | Upload or replace the caller's agent WASM | +| `POST` | `/api/upload_core` | Admin session | Upload or replace the core simulation WASM | + +Agent and core WASM uploads are limited to 1 MiB and validated in the +sandboxed runtime before they are persisted. Existing signed request headers +remain accepted for compatibility with older clients. + +## CLI + +The binary also provides an authenticated client. New installations use the +admin keypair directly: ```sh cargo run --release -- cli \ - --server http://127.0.0.1:84080\ - --root-ca data/certificates/root-ca.pem \ - --client-cert data/certificates/admin-client.pem \ - --client-key data/certificates/admin-client-key.pem \ - status + --client-key data/keys/admin-private.pem \ + create-user alice ``` -Available subcommands (see [src/cli.rs](src/cli.rs)): `status`, -`create-user `, `delete-user `, `stop-game`, -`start-game`, `update-agent `. +`create-user` prints the identity ID and private key as JSON. For commands run +as a player, use that player's private key and identity ID: -## Building the example WASM agent +```sh +cargo run --release -- cli \ + --client-id \ + --client-key alice-private.pem \ + update-agent example-wasm-agent/target/wasm32-unknown-unknown/release/example_wasm_agent.wasm +``` -[example-wasm-agent/](example-wasm-agent) is a standalone crate implementing -a minimal Extism plugin agent matching the ABI expected by -[src/wasm_agent.rs](src/wasm_agent.rs) (a JSON `decide` call taking -`{abi_version, tick, x, y, adjacent_terrain}` and returning -`{abi_version, action}`). Movement is a deterministic function of the tick -and position — no RNG, clock, or host access is used, so replays are -reproducible. +Legacy installations can pass `--client-cert` and `--client-key` to derive the +historical certificate-based admin ID. The CLI exchanges a challenge for a +session token before making protected requests. -Build it to a `.wasm` file: +## Example Agent + +`example-wasm-agent/` is a standalone crate implementing the Extism plugin ABI +used by `src/wasm_agent.rs`. Build it with: ```sh cd example-wasm-agent -rustup target add wasm32-unknown-unknown # if not already installed +rustup target add wasm32-unknown-unknown cargo build --release --target wasm32-unknown-unknown ``` The compiled module is at -`example-wasm-agent/target/wasm32-unknown-unknown/release/example_wasm_agent.wasm`. - -## Uploading the example agent - -First create a player identity (as admin), then upload the agent WASM signed -with that player's own certificate: - -```sh -# As admin, create a player and save the returned certificate/private key: -cargo run --release -- cli \ - --client-cert data/certificates/admin-client.pem \ - --client-key data/certificates/admin-client-key.pem \ - create-user alice -# -> write the returned certificate_pem/private_key_pem to alice.pem / alice-key.pem - -# As alice, upload the compiled agent: -cargo run --release -- cli \ - --client-cert alice.pem \ - --client-key alice-key.pem \ - update-agent example-wasm-agent/target/wasm32-unknown-unknown/release/example_wasm_agent.wasm -``` - -Core simulation WASM can be uploaded the same way by an admin using -`POST /api/upload_core` (no dedicated CLI subcommand yet — use `curl` as shown -above, or extend the CLI client following the same pattern as `update-agent`). +`example-wasm-agent/target/wasm32-unknown-unknown/release/example_wasm_agent.wasm`. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9c38311 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,15 @@ +services: + wolloseum: + build: . + image: wolloseum:local + container_name: wolloseum + restart: unless-stopped + environment: + WOLLOSEUM_BIND: 0.0.0.0:8080 + WOLLOSEUM_DATA_DIR: /var/lib/wolloseum + WOLLOSEUM_UID: "${WOLLOSEUM_UID:-1000}" + WOLLOSEUM_GID: "${WOLLOSEUM_GID:-1000}" + ports: + - "8080:8080" + volumes: + - ./data:/var/lib/wolloseum diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..a6c85d2 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +uid=${WOLLOSEUM_UID:-10001} +gid=${WOLLOSEUM_GID:-10001} +data_dir=${WOLLOSEUM_DATA_DIR:-/var/lib/wolloseum} + +case "$uid" in + ''|*[!0-9]*) echo "WOLLOSEUM_UID must be a numeric user ID" >&2; exit 1 ;; +esac +case "$gid" in + ''|*[!0-9]*) echo "WOLLOSEUM_GID must be a numeric group ID" >&2; exit 1 ;; +esac + +mkdir -p "$data_dir" +chown -R "$uid:$gid" "$data_dir" +exec setpriv --reuid="$uid" --regid="$gid" --clear-groups "$@" \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 1b4e858..f80420c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,15 +2,13 @@ use std::error::Error; use std::fs; use std::net::SocketAddr; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use clap::{Args, Parser, Subcommand}; -use rcgen::KeyPair; +use rcgen::{KeyPair, PKCS_ED25519}; use reqwest::{Client, Method}; use ring::signature::Ed25519KeyPair; -use serde::Serialize; #[derive(Debug, Parser)] #[command( @@ -33,10 +31,10 @@ enum TopLevelCommand { #[derive(Debug, Args)] struct ServeArguments { - /// Directory for certificates and the SQLite database. + /// Directory for keypairs and the SQLite database. #[arg(long, env = "WOLLOSEUM_DATA_DIR", default_value = "data")] data_dir: PathBuf, - /// HTTP listener address. + /// HTTP listener address, usually behind a TLS reverse proxy. #[arg(long, env = "WOLLOSEUM_BIND", default_value = "127.0.0.1:8080")] bind: SocketAddr, } @@ -51,20 +49,18 @@ struct ClientArguments { default_value = "http://127.0.0.1:8080" )] server: String, - /// Client certificate whose PEM fingerprint is the identity ID. - #[arg( - long, - global = true, - env = "WOLLOSEUM_CLIENT_CERT", - default_value = "data/certificates/admin-client.pem" - )] - client_cert: PathBuf, - /// Private key for the selected client certificate. + /// Identity ID for legacy certificate-fingerprint identities. + #[arg(long, global = true, env = "WOLLOSEUM_CLIENT_ID")] + client_id: Option, + /// Optional legacy certificate used to derive its historical identity ID. + #[arg(long, global = true, env = "WOLLOSEUM_CLIENT_CERT")] + client_cert: Option, + /// Ed25519 private key for the selected identity. #[arg( long, global = true, env = "WOLLOSEUM_CLIENT_KEY", - default_value = "data/certificates/admin-client-key.pem" + default_value = "data/keys/admin-private.pem" )] client_key: PathBuf, #[command(subcommand)] @@ -75,9 +71,9 @@ struct ClientArguments { enum ClientCommand { /// Read the current game status. Status, - /// Create a user and print their certificate and private key as JSON. + /// Create a user and print its identity bundle as JSON. CreateUser { username: String }, - /// Delete a player by certificate ID. + /// Delete a player by identity ID. DeleteUser { client_id: String }, /// Pause the game. StopGame, @@ -97,20 +93,25 @@ pub async fn run(arguments: Arguments) -> Result<(), Box> { } async fn run_client(arguments: ClientArguments) -> Result<(), Box> { + let _ = rustls::crypto::ring::default_provider().install_default(); let client = Client::builder().build()?; - - let (method, path, body, needs_signature) = match arguments.command { + let base_url = arguments.server.trim_end_matches('/'); + let mut created_identity = None; + let (method, path, body, requires_auth) = match arguments.command { ClientCommand::Status => (Method::GET, "/api/status", Vec::new(), false), - ClientCommand::CreateUser { username } => ( - Method::POST, - "/api/create_user", - serde_json::to_vec(&CreateUserBody { username })?, - true, - ), + ClientCommand::CreateUser { username } => { + let key = KeyPair::generate_for(&PKCS_ED25519)?; + let body = serde_json::to_vec(&serde_json::json!({ + "username": username, + "public_key": BASE64.encode(key.public_key_raw()), + }))?; + created_identity = Some((username, key.serialize_pem())); + (Method::POST, "/api/create_user", body, true) + } ClientCommand::DeleteUser { client_id } => ( Method::POST, "/api/delete_user", - serde_json::to_vec(&DeleteUserBody { client_id })?, + serde_json::to_vec(&serde_json::json!({ "client_id": client_id }))?, true, ), ClientCommand::StopGame => (Method::POST, "/api/stop_game", Vec::new(), true), @@ -120,65 +121,87 @@ async fn run_client(arguments: ClientArguments) -> Result<(), Box> { } }; - let url = format!("{}{}", arguments.server.trim_end_matches('/'), path); - let mut request = client.request(method.clone(), url).body(body.clone()); - if needs_signature { - let certificate_pem = fs::read_to_string(&arguments.client_cert)?; - let key = KeyPair::from_pem(&fs::read_to_string(&arguments.client_key)?)?; - let signer = Ed25519KeyPair::from_pkcs8(&key.serialize_der()) + let mut request = client + .request(method, format!("{base_url}{path}")) + .body(body); + if requires_auth { + let private_key = KeyPair::from_pem(&fs::read_to_string(&arguments.client_key)?)?; + let signer = Ed25519KeyPair::from_pkcs8(&private_key.serialize_der()) .map_err(|_| "client private key is not a valid Ed25519 PKCS#8 key")?; - let client_id = certificate_id(&certificate_pem); - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let nonce = uuid::Uuid::new_v4().to_string(); - let message = signature_message(method.as_str(), path, timestamp, &nonce, &body); - let signature = BASE64.encode(signer.sign(&message).as_ref()); - request = request - .header("x-client-id", client_id) - .header("x-request-timestamp", timestamp.to_string()) - .header("x-request-nonce", nonce) - .header("x-request-signature", signature); + let client_id = if let Some(client_id) = arguments.client_id { + client_id + } else if let Some(certificate_path) = arguments.client_cert { + certificate_id(&fs::read_to_string(certificate_path)?) + } else { + public_key_id(private_key.public_key_raw()) + }; + let challenge_response_body = client + .post(format!("{base_url}/api/auth/challenge")) + .header("content-type", "application/json") + .body(serde_json::to_vec( + &serde_json::json!({ "client_id": client_id }), + )?) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + let challenge_response: serde_json::Value = + serde_json::from_slice(&challenge_response_body)?; + let challenge_text = challenge_response["challenge"] + .as_str() + .ok_or("missing authentication challenge")?; + let challenge = BASE64.decode(challenge_text)?; + let login_response_body = client + .post(format!("{base_url}/api/auth/login")) + .header("content-type", "application/json") + .body(serde_json::to_vec(&serde_json::json!({ + "client_id": client_id, + "challenge": challenge_text, + "signature": BASE64.encode(signer.sign(&challenge).as_ref()), + }))?) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + let login_response: serde_json::Value = serde_json::from_slice(&login_response_body)?; + let token = login_response["token"] + .as_str() + .ok_or("missing session token")?; + request = request.header("authorization", format!("Bearer {token}")); } let response = request.send().await?; let status = response.status(); - let body = response.bytes().await?; + let response_body = response.bytes().await?; if !status.is_success() { return Err(format!( "server returned {status}: {}", - String::from_utf8_lossy(&body) + String::from_utf8_lossy(&response_body) ) .into()); } - if body.is_empty() { + if response_body.is_empty() { println!("HTTP {status}"); - } else if let Ok(json) = serde_json::from_slice::(&body) { + } else if let Ok(mut json) = serde_json::from_slice::(&response_body) { + if let Some((username, private_key_pem)) = created_identity { + json["username"] = serde_json::Value::String(username); + json["private_key_pem"] = serde_json::Value::String(private_key_pem); + } println!("{}", serde_json::to_string_pretty(&json)?); } else { - println!("{}", String::from_utf8_lossy(&body)); + println!("{}", String::from_utf8_lossy(&response_body)); } Ok(()) } -#[derive(Serialize)] -struct CreateUserBody { - username: String, -} - -#[derive(Serialize)] -struct DeleteUserBody { - client_id: String, -} - -fn signature_message( - method: &str, - path: &str, - timestamp: u64, - nonce: &str, - body: &[u8], -) -> Vec { - let mut message = format!("{method}\n{path}\n{timestamp}\n{nonce}\n").into_bytes(); - message.extend_from_slice(body); - message +fn public_key_id(public_key: &[u8]) -> String { + ring::digest::digest(&ring::digest::SHA256, public_key) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } fn certificate_id(certificate_pem: &str) -> String { @@ -202,7 +225,7 @@ mod tests { } #[test] - fn cli_can_select_user_credentials_for_upload() { + fn cli_can_select_legacy_or_keypair_credentials_for_upload() { assert!( Arguments::try_parse_from([ "wolloseum", @@ -216,5 +239,18 @@ mod tests { ]) .is_ok() ); + assert!( + Arguments::try_parse_from([ + "wolloseum", + "cli", + "--client-id", + "identity-id", + "--client-key", + "player-key.pem", + "update-agent", + "agent.wasm", + ]) + .is_ok() + ); } } diff --git a/src/server.rs b/src/server.rs index 84b57fc..32db904 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,7 +1,7 @@ use std::error::Error; use std::fs::{self, OpenOptions}; use std::io::Write; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -14,13 +14,10 @@ use axum::http::{HeaderMap, Method, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; -use axum_server::tls_rustls::RustlsConfig; use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; -use rcgen::{ - BasicConstraints, Certificate, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, - Issuer, KeyPair, KeyUsagePurpose, PKCS_ED25519, SanType, -}; +use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD}; +use rcgen::{KeyPair, PKCS_ED25519}; +use ring::rand::{SecureRandom, SystemRandom}; use ring::signature::{ED25519, UnparsedPublicKey}; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; @@ -32,12 +29,176 @@ use crate::wasm_agent::WasmAgent; const MAX_WASM_BYTES: usize = 1_048_576; const MAX_REQUEST_SKEW_SECONDS: u64 = 60; const REQUEST_NONCE_MAX_BYTES: usize = 128; +const SESSION_LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60; +const CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60; + +async fn auth_challenge( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let now = unix_time(); + let database = state.database.lock().map_err(ApiError::internal)?; + let active = database + .query_row( + "SELECT active FROM identities WHERE id = ?1", + [&request.client_id], + |row| row.get::<_, bool>(0), + ) + .optional() + .map_err(ApiError::internal)? + .unwrap_or(false); + if !active { + return Err(ApiError::unauthorized()); + } + let mut random_bytes = [0u8; 32]; + SystemRandom::new() + .fill(&mut random_bytes) + .map_err(|_| ApiError::internal("secure random generation failed"))?; + let challenge = BASE64.encode(random_bytes); + database + .execute( + "DELETE FROM auth_challenges WHERE expires_at < ?1", + [now as i64], + ) + .map_err(ApiError::internal)?; + database + .execute( + "INSERT INTO auth_challenges (identity_id, challenge_hash, expires_at) VALUES (?1, ?2, ?3)", + params![request.client_id, digest_hex(&random_bytes), now.saturating_add(CHALLENGE_LIFETIME_SECONDS) as i64], + ) + .map_err(ApiError::internal)?; + Ok(Json(AuthChallengeResponse { challenge })) +} + +async fn auth_login( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let challenge = BASE64 + .decode(request.challenge) + .map_err(|_| ApiError::unauthorized())?; + let signature = BASE64 + .decode(request.signature) + .map_err(|_| ApiError::unauthorized())?; + let challenge_hash = digest_hex(&challenge); + let now = unix_time(); + let mut database = state.database.lock().map_err(ApiError::internal)?; + let transaction = database.transaction().map_err(ApiError::internal)?; + let identity = transaction + .query_row( + "SELECT identities.public_key, identities.active, auth_challenges.expires_at + FROM identities JOIN auth_challenges ON identities.id = auth_challenges.identity_id + WHERE identities.id = ?1 AND auth_challenges.challenge_hash = ?2", + params![request.client_id, challenge_hash], + |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, bool>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional() + .map_err(ApiError::internal)? + .ok_or_else(ApiError::unauthorized)?; + if !identity.1 + || identity.2 <= now as i64 + || !verify_signature(&identity.0, &challenge, &signature) + { + return Err(ApiError::unauthorized()); + } + let deleted = transaction + .execute( + "DELETE FROM auth_challenges WHERE identity_id = ?1 AND challenge_hash = ?2 AND expires_at > ?3", + params![request.client_id, challenge_hash, now as i64], + ) + .map_err(ApiError::internal)?; + if deleted != 1 { + return Err(ApiError::unauthorized()); + } + let mut token_bytes = [0u8; 32]; + SystemRandom::new() + .fill(&mut token_bytes) + .map_err(|_| ApiError::internal("secure random generation failed"))?; + let token = URL_SAFE_NO_PAD.encode(token_bytes); + let expires_at = now.saturating_add(SESSION_LIFETIME_SECONDS); + transaction + .execute( + "INSERT INTO sessions (token_hash, identity_id, expires_at, last_used) VALUES (?1, ?2, ?3, ?4)", + params![digest_hex(token.as_bytes()), request.client_id, expires_at as i64, now as i64], + ) + .map_err(ApiError::internal)?; + transaction.commit().map_err(ApiError::internal)?; + Ok(Json(AuthLoginResponse { token, expires_at })) +} + +async fn current_identity( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let token = bearer_token(&headers)?; + let client_id = authenticate_session(&state, token, false)?; + let database = state.database.lock().map_err(ApiError::internal)?; + let identity = database + .query_row( + "SELECT identities.username, identities.is_admin, EXISTS(SELECT 1 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM agents WHERE agents.identity_id = identities.id) FROM identities WHERE identities.id = ?1", + [&client_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, bool>(1)?, + row.get::<_, bool>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) + .map_err(ApiError::internal)?; + Ok(Json(IdentityInfo { + client_id, + username: identity.0, + is_admin: identity.1, + has_agent: identity.2, + agent_sha256: identity.3, + })) +} + +async fn list_users( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let token = bearer_token(&headers)?; + authenticate_session(&state, token, true)?; + let database = state.database.lock().map_err(ApiError::internal)?; + let mut statement = database + .prepare("SELECT identities.id, identities.username, identities.is_admin, EXISTS(SELECT 1 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM agents WHERE agents.identity_id = identities.id) FROM identities ORDER BY identities.is_admin DESC, identities.username COLLATE NOCASE") + .map_err(ApiError::internal)?; + let users = statement + .query_map([], |row| { + Ok(IdentityInfo { + client_id: row.get(0)?, + username: row.get(1)?, + is_admin: row.get(2)?, + has_agent: row.get(3)?, + agent_sha256: row.get(4)?, + }) + }) + .map_err(ApiError::internal)? + .collect::, _>>() + .map_err(ApiError::internal)?; + Ok(Json(users)) +} + +fn bearer_token(headers: &HeaderMap) -> Result<&str, ApiError> { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or_else(ApiError::unauthorized) +} #[derive(Clone)] struct AppState { database: Arc>, - root_certificate_pem: Arc, - root_key_pem: Arc, game_running: Arc, event_sender: broadcast::Sender, event_sequence: Arc>, @@ -51,141 +212,64 @@ struct StateEvent { } struct Pki { - root_certificate_pem: String, - root_key_pem: String, admin_id: String, admin_public_key: Vec, - admin_certificate_pem: String, + admin_key_path: PathBuf, } impl Pki { - fn initialize(directory: &Path) -> Result> { - fs::create_dir_all(directory)?; - set_private_directory_permissions(directory)?; + fn initialize(data_directory: &Path) -> Result> { + let legacy_directory = data_directory.join("certificates"); + let legacy_certificate_path = legacy_directory.join("admin-client.pem"); + let legacy_key_path = legacy_directory.join("admin-client-key.pem"); + let key_directory = data_directory.join("keys"); + fs::create_dir_all(&key_directory)?; + set_private_directory_permissions(&key_directory)?; + let key_path = key_directory.join("admin-private.pem"); - let root_certificate_path = directory.join("root-ca.pem"); - let root_key_path = directory.join("root-ca-key.pem"); - let admin_certificate_path = directory.join("admin-client.pem"); - let admin_key_path = directory.join("admin-client-key.pem"); - let server_certificate_path = directory.join("server.pem"); - let server_key_path = directory.join("server-key.pem"); - - let root_certificate_exists = root_certificate_path.exists(); - let root_key_exists = root_key_path.exists(); - if root_certificate_exists != root_key_exists { - return Err("root CA certificate/key pair is incomplete".into()); - } - - if !root_certificate_exists { - if admin_certificate_path.exists() - || admin_key_path.exists() - || server_certificate_path.exists() - || server_key_path.exists() - { - return Err( - "certificate files exist without a root CA; refusing partial bootstrap".into(), - ); - } - let root_key = KeyPair::generate_for(&PKCS_ED25519)?; - let mut root_params = CertificateParams::default(); - root_params - .distinguished_name - .push(DnType::CommonName, "Wolloseum Root CA"); - root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - root_params.key_usages = vec![ - KeyUsagePurpose::DigitalSignature, - KeyUsagePurpose::KeyCertSign, - KeyUsagePurpose::CrlSign, - ]; - let root_certificate = root_params.self_signed(&root_key)?; - let root_certificate_pem = root_certificate.pem(); - let root_key_pem = root_key.serialize_pem(); - write_new_file( - &root_certificate_path, - root_certificate_pem.as_bytes(), - false, - )?; - write_new_file(&root_key_path, root_key_pem.as_bytes(), true)?; - - let issuer = - Issuer::from_ca_cert_pem(&root_certificate_pem, KeyPair::from_pem(&root_key_pem)?)?; - let (admin_certificate, admin_key) = - issue_client_certificate("Wolloseum Admin", &issuer)?; - write_new_file( - &admin_certificate_path, - admin_certificate.pem().as_bytes(), - false, - )?; - write_new_file(&admin_key_path, admin_key.serialize_pem().as_bytes(), true)?; - } else if !admin_certificate_path.exists() || !admin_key_path.exists() { - return Err( - "admin certificate/key is missing; refusing to generate a replacement".into(), - ); - } - - let root_certificate_pem = fs::read_to_string(&root_certificate_path)?; - let root_key_pem = fs::read_to_string(&root_key_path)?; - let issuer = - Issuer::from_ca_cert_pem(&root_certificate_pem, KeyPair::from_pem(&root_key_pem)?)?; - - let server_certificate_exists = server_certificate_path.exists(); - let server_key_exists = server_key_path.exists(); - if server_certificate_exists != server_key_exists { - return Err("HTTPS server certificate/key pair is incomplete".into()); - } - if !server_certificate_exists { - let (certificate, key) = issue_server_certificate(&issuer)?; - write_new_file( - &server_certificate_path, - certificate.pem().as_bytes(), - false, - )?; - write_new_file(&server_key_path, key.serialize_pem().as_bytes(), true)?; - } - - let admin_certificate_pem = fs::read_to_string(&admin_certificate_path)?; - let admin_key = KeyPair::from_pem(&fs::read_to_string(&admin_key_path)?)?; - let admin_id = certificate_id(&admin_certificate_pem); + let (admin_key, admin_id, admin_key_path) = + if legacy_certificate_path.exists() && legacy_key_path.exists() { + let certificate = fs::read_to_string(&legacy_certificate_path)?; + let key = KeyPair::from_pem(&fs::read_to_string(legacy_key_path)?)?; + ( + key, + certificate_id(&certificate), + legacy_directory.join("admin-client-key.pem"), + ) + } else if key_path.exists() { + let key = KeyPair::from_pem(&fs::read_to_string(&key_path)?)?; + let id = public_key_id(key.public_key_raw()); + (key, id, key_path.clone()) + } else { + let key = KeyPair::generate_for(&PKCS_ED25519)?; + let id = public_key_id(key.public_key_raw()); + write_new_file(&key_path, key.serialize_pem().as_bytes(), true)?; + (key, id, key_path.clone()) + }; Ok(Self { - root_certificate_pem, - root_key_pem, admin_id, admin_public_key: admin_key.public_key_raw().to_vec(), - admin_certificate_pem, + admin_key_path, }) } } pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box> { - let certificate_directory = data_directory.join("certificates"); - let pki = Pki::initialize(&certificate_directory)?; + let pki = Pki::initialize(&data_directory)?; let database_path = data_directory.join("wolloseum.sqlite3"); - let database = initialize_database( - &database_path, - &pki.admin_id, - &pki.admin_public_key, - &pki.admin_certificate_pem, + let database = initialize_database(&database_path, &pki.admin_id, &pki.admin_public_key)?; + let admin_id: String = database.query_row( + "SELECT id FROM identities WHERE username = 'admin'", + [], + |row| row.get(0), )?; - eprintln!("Admin certificate ID: {}", pki.admin_id); - eprintln!( - "Admin client certificate: {}", - certificate_directory.join("admin-client.pem").display() - ); - eprintln!( - "Admin private key: {}", - certificate_directory.join("admin-client-key.pem").display() - ); - eprintln!( - "Root CA certificate: {}", - certificate_directory.join("root-ca.pem").display() - ); + eprintln!("Admin identity ID: {}", admin_id); + eprintln!("Admin private key: {}", pki.admin_key_path.display()); let state = AppState { database: Arc::new(Mutex::new(database)), - root_certificate_pem: Arc::new(pki.root_certificate_pem), - root_key_pem: Arc::new(pki.root_key_pem), game_running: Arc::new(AtomicBool::new(true)), event_sender: broadcast::channel(256).0, event_sequence: Arc::new(Mutex::new(0)), @@ -193,6 +277,13 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box let app = Router::new() .route("/", get(user_ui)) .route("/admin", get(admin_ui)) + .route("/viewer", get(viewer_ui)) + .route("/assets/app.css", get(app_css)) + .route("/assets/app.js", get(app_js)) + .route("/api/auth/challenge", post(auth_challenge)) + .route("/api/auth/login", post(auth_login)) + .route("/api/me", get(current_identity)) + .route("/api/users", get(list_users)) .route("/api/status", get(game_status)) .route("/api/events", get(websocket_events)) .route("/api/create_user", post(create_user)) @@ -205,9 +296,8 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box .with_state(state); eprintln!("Wolloseum HTTP listening on http://{address}"); - axum_server::bind(address) - .serve(app.into_make_service()) - .await?; + let listener = tokio::net::TcpListener::bind(address).await?; + axum::serve(listener, app).await?; Ok(()) } @@ -215,7 +305,6 @@ fn initialize_database( path: &Path, admin_id: &str, admin_public_key: &[u8], - admin_certificate_pem: &str, ) -> Result> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -250,14 +339,28 @@ fn initialize_database( wasm BLOB NOT NULL, sha256 TEXT NOT NULL, uploaded_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS auth_challenges ( + identity_id TEXT NOT NULL REFERENCES identities(id) ON DELETE CASCADE, + challenge_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (identity_id, challenge_hash) + ); + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + identity_id TEXT NOT NULL REFERENCES identities(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + last_used INTEGER NOT NULL );", )?; connection.execute( "INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, active) - VALUES (?1, 'admin', ?2, ?3, 1, 1) + VALUES (?1, 'admin', ?2, '', 1, 1) ON CONFLICT(id) DO UPDATE SET public_key = excluded.public_key, - certificate_pem = excluded.certificate_pem, is_admin = 1, active = 1", - params![admin_id, admin_public_key, admin_certificate_pem], + is_admin = 1, active = 1 + ON CONFLICT(username) DO UPDATE SET public_key = excluded.public_key, + is_admin = 1, active = 1", + params![admin_id, admin_public_key], )?; Ok(connection) } @@ -273,14 +376,14 @@ impl ApiError { fn unauthorized() -> Self { Self( StatusCode::UNAUTHORIZED, - "invalid or missing request signature".to_string(), + "invalid or missing authentication".to_string(), ) } fn forbidden() -> Self { Self( StatusCode::FORBIDDEN, - "admin certificate required".to_string(), + "administrator identity required".to_string(), ) } @@ -298,13 +401,45 @@ impl IntoResponse for ApiError { #[derive(Serialize)] struct ApiIdentity { client_id: String, - certificate_pem: String, - private_key_pem: String, + username: String, } #[derive(Deserialize)] struct CreateUserRequest { username: String, + public_key: String, +} + +#[derive(Deserialize)] +struct AuthChallengeRequest { + client_id: String, +} + +#[derive(Serialize)] +struct AuthChallengeResponse { + challenge: String, +} + +#[derive(Clone, Deserialize)] +struct AuthLoginRequest { + client_id: String, + challenge: String, + signature: String, +} + +#[derive(Serialize)] +struct AuthLoginResponse { + token: String, + expires_at: u64, +} + +#[derive(Serialize)] +struct IdentityInfo { + client_id: String, + username: String, + is_admin: bool, + has_agent: bool, + agent_sha256: Option, } #[derive(Deserialize)] @@ -325,6 +460,14 @@ fn authenticate( body: &[u8], require_admin: bool, ) -> Result { + if let Some(value) = headers.get(axum::http::header::AUTHORIZATION) { + let authorization = value.to_str().map_err(|_| ApiError::unauthorized())?; + let token = authorization + .strip_prefix("Bearer ") + .ok_or_else(ApiError::unauthorized)?; + return authenticate_session(state, token, require_admin); + } + let client_id = header(headers, "x-client-id")?; let timestamp = header(headers, "x-request-timestamp")? .parse::() @@ -392,6 +535,57 @@ fn authenticate( Ok(client_id.to_string()) } +fn authenticate_session( + state: &AppState, + token: &str, + require_admin: bool, +) -> Result { + let token_hash = digest_hex(token.as_bytes()); + let now = unix_time(); + let mut database = state.database.lock().map_err(ApiError::internal)?; + let transaction = database.transaction().map_err(ApiError::internal)?; + let identity = transaction + .query_row( + "SELECT identities.id, identities.is_admin, identities.active, sessions.expires_at + FROM sessions JOIN identities ON identities.id = sessions.identity_id + WHERE sessions.token_hash = ?1", + [&token_hash], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, bool>(1)?, + row.get::<_, bool>(2)?, + row.get::<_, i64>(3)?, + )) + }, + ) + .optional() + .map_err(ApiError::internal)? + .ok_or_else(ApiError::unauthorized)?; + if !identity.2 || identity.3 <= now as i64 { + transaction + .execute("DELETE FROM sessions WHERE token_hash = ?1", [&token_hash]) + .map_err(ApiError::internal)?; + transaction.commit().map_err(ApiError::internal)?; + return Err(ApiError::unauthorized()); + } + if require_admin && !identity.1 { + return Err(ApiError::forbidden()); + } + transaction + .execute( + "UPDATE sessions SET expires_at = ?1, last_used = ?2 WHERE token_hash = ?3", + params![ + now.saturating_add(SESSION_LIFETIME_SECONDS) as i64, + now as i64, + token_hash + ], + ) + .map_err(ApiError::internal)?; + transaction.commit().map_err(ApiError::internal)?; + Ok(identity.0) +} + fn header<'a>(headers: &'a HeaderMap, name: &'static str) -> Result<&'a str, ApiError> { headers .get(name) @@ -439,21 +633,21 @@ async fn create_user( )); } - let root_key = KeyPair::from_pem(&state.root_key_pem).map_err(ApiError::internal)?; - let issuer = Issuer::from_ca_cert_pem(&state.root_certificate_pem, root_key) - .map_err(ApiError::internal)?; - let (certificate, key) = - issue_client_certificate(username, &issuer).map_err(ApiError::internal)?; - let certificate_pem = certificate.pem(); - let client_id = certificate_id(&certificate_pem); - let public_key = key.public_key_raw().to_vec(); - let private_key_pem = key.serialize_pem(); + let public_key = BASE64 + .decode(request.public_key) + .map_err(|_| ApiError::bad_request("public_key must be base64-encoded Ed25519 bytes"))?; + if public_key.len() != 32 { + return Err(ApiError::bad_request( + "public_key must contain a 32-byte Ed25519 public key", + )); + } + let client_id = public_key_id(&public_key); let database = state.database.lock().map_err(ApiError::internal)?; database .execute( - "INSERT INTO identities (id, username, public_key, certificate_pem) VALUES (?1, ?2, ?3, ?4)", - params![client_id, username, public_key, certificate_pem], + "INSERT INTO identities (id, username, public_key, certificate_pem) VALUES (?1, ?2, ?3, '')", + params![client_id, username, public_key], ) .map_err(|error| { if error.sqlite_error_code() == Some(rusqlite::ErrorCode::ConstraintViolation) { @@ -466,8 +660,7 @@ async fn create_user( Ok(Json(ApiIdentity { client_id, - certificate_pem, - private_key_pem, + username: username.to_string(), })) } @@ -756,69 +949,34 @@ fn publish_state_event_locked( } async fn user_ui() -> Html<&'static str> { - Html(USER_UI) + Html(include_str!("../web/user.html")) } async fn admin_ui() -> Html<&'static str> { - Html(ADMIN_UI) + Html(include_str!("../web/admin.html")) } -const USER_UI: &str = r#" - -Wolloseum -

Wolloseum

Connecting to live updates...

Administration

-

Agent upload

Authenticate with your issued certificate and sign the exact WASM request body.

-
POST /api/update_agent
-X-Client-Id, X-Request-Timestamp, X-Request-Nonce, X-Request-Signature
-

Live state changes

    - -"#; - -const ADMIN_UI: &str = r#" - -Wolloseum Admin -

    Wolloseum administration

    Player view

    Connecting to live updates...

    -

    Sign each API request with the admin client key. The signed message is method, path, timestamp, nonce, then the exact body bytes.

    -
    • POST /api/create_user
    • POST /api/delete_user
    • POST /api/stop_game
    • POST /api/start_game
    • POST /api/upload_core
    -
    Headers: X-Client-Id, X-Request-Timestamp, X-Request-Nonce, X-Request-Signature
    -

    Live state changes

      - -"#; - -fn issue_client_certificate( - name: &str, - issuer: &Issuer<'_, KeyPair>, -) -> Result<(Certificate, KeyPair), Box> { - let key = KeyPair::generate_for(&PKCS_ED25519)?; - let mut params = CertificateParams::new(Vec::::new())?; - params.distinguished_name.push(DnType::CommonName, name); - params.use_authority_key_identifier_extension = true; - params.key_usages.push(KeyUsagePurpose::DigitalSignature); - params - .extended_key_usages - .push(ExtendedKeyUsagePurpose::ClientAuth); - let certificate = params.signed_by(&key, issuer)?; - Ok((certificate, key)) +async fn viewer_ui() -> Html<&'static str> { + Html(include_str!("../web/viewer.html")) } -fn issue_server_certificate( - issuer: &Issuer<'_, KeyPair>, -) -> Result<(Certificate, KeyPair), Box> { - let key = KeyPair::generate_for(&PKCS_ED25519)?; - let mut params = CertificateParams::new(vec!["localhost".to_string()])?; - params - .distinguished_name - .push(DnType::CommonName, "localhost"); - params - .subject_alt_names - .push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST))); - params.use_authority_key_identifier_extension = true; - params.key_usages.push(KeyUsagePurpose::DigitalSignature); - params - .extended_key_usages - .push(ExtendedKeyUsagePurpose::ServerAuth); - let certificate = params.signed_by(&key, issuer)?; - Ok((certificate, key)) +async fn app_css() -> Response { + ( + [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")], + include_str!("../web/app.css"), + ) + .into_response() +} + +async fn app_js() -> Response { + ( + [( + axum::http::header::CONTENT_TYPE, + "text/javascript; charset=utf-8", + )], + include_str!("../web/app.js"), + ) + .into_response() } fn certificate_id(certificate_pem: &str) -> String { @@ -830,6 +988,18 @@ fn certificate_id(certificate_pem: &str) -> String { .collect() } +fn public_key_id(public_key: &[u8]) -> String { + digest_hex(public_key) +} + +fn digest_hex(value: &[u8]) -> String { + ring::digest::digest(&ring::digest::SHA256, value) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + fn unix_time() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -889,24 +1059,18 @@ mod tests { use std::sync::{Arc, Mutex}; fn test_state(directory: &Path) -> (AppState, String, KeyPair) { - let cert_directory = directory.join("certificates"); - let pki = Pki::initialize(&cert_directory).unwrap(); + let pki = Pki::initialize(directory).unwrap(); let admin_id = pki.admin_id.clone(); - let admin_key = KeyPair::from_pem( - &fs::read_to_string(cert_directory.join("admin-client-key.pem")).unwrap(), - ) - .unwrap(); + let admin_key = + KeyPair::from_pem(&fs::read_to_string(&pki.admin_key_path).unwrap()).unwrap(); let database = super::initialize_database( &directory.join("wolloseum.sqlite3"), &pki.admin_id, &pki.admin_public_key, - &pki.admin_certificate_pem, ) .unwrap(); let state = AppState { database: Arc::new(Mutex::new(database)), - root_certificate_pem: Arc::new(pki.root_certificate_pem), - root_key_pem: Arc::new(pki.root_key_pem), game_running: Arc::new(AtomicBool::new(true)), event_sender: tokio::sync::broadcast::channel(256).0, event_sequence: Arc::new(Mutex::new(0)), @@ -974,6 +1138,105 @@ mod tests { wat::parse_str(source).unwrap() } + #[tokio::test] + async fn challenge_login_consumes_challenge_and_refreshes_hashed_session() { + let directory = tempfile::tempdir().unwrap(); + let (state, admin_id, admin_key) = test_state(directory.path()); + let Json(challenge) = super::auth_challenge( + State(state.clone()), + Json(super::AuthChallengeRequest { + client_id: admin_id.clone(), + }), + ) + .await + .unwrap(); + let challenge_bytes = BASE64.decode(&challenge.challenge).unwrap(); + let signer = Ed25519KeyPair::from_pkcs8(&admin_key.serialize_der()).unwrap(); + let signature = BASE64.encode(signer.sign(&challenge_bytes).as_ref()); + let login_request = super::AuthLoginRequest { + client_id: admin_id.clone(), + challenge: challenge.challenge, + signature, + }; + let Json(session) = super::auth_login(State(state.clone()), Json(login_request.clone())) + .await + .unwrap(); + assert!(session.expires_at >= unix_time() + super::SESSION_LIFETIME_SECONDS - 1); + + let replay = super::auth_login(State(state.clone()), Json(login_request)).await; + assert!(replay.is_err()); + assert_eq!( + super::authenticate_session(&state, &session.token, true).unwrap(), + admin_id + ); + + let token_hash = super::digest_hex(session.token.as_bytes()); + let refreshed_expiry: i64 = state + .database + .lock() + .unwrap() + .query_row( + "SELECT expires_at FROM sessions WHERE token_hash = ?1", + [&token_hash], + |row| row.get(0), + ) + .unwrap(); + assert!(refreshed_expiry as u64 >= unix_time() + super::SESSION_LIFETIME_SECONDS - 1); + assert_ne!(token_hash, session.token); + + let player_key = KeyPair::generate_for(&PKCS_ED25519).unwrap(); + let player_id = super::public_key_id(player_key.public_key_raw()); + state + .database + .lock() + .unwrap() + .execute( + "INSERT INTO identities (id, username, public_key, certificate_pem) VALUES (?1, 'player', ?2, '')", + rusqlite::params![player_id, player_key.public_key_raw()], + ) + .unwrap(); + let Json(player_challenge) = super::auth_challenge( + State(state.clone()), + Json(super::AuthChallengeRequest { + client_id: player_id.clone(), + }), + ) + .await + .unwrap(); + let player_signer = Ed25519KeyPair::from_pkcs8(&player_key.serialize_der()).unwrap(); + let player_signature = BASE64.encode( + player_signer + .sign(&BASE64.decode(&player_challenge.challenge).unwrap()) + .as_ref(), + ); + let Json(player_session) = super::auth_login( + State(state.clone()), + Json(super::AuthLoginRequest { + client_id: player_id, + challenge: player_challenge.challenge, + signature: player_signature, + }), + ) + .await + .unwrap(); + let mut player_headers = HeaderMap::new(); + player_headers.insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {}", player_session.token)).unwrap(), + ); + assert!( + authenticate( + &state, + &player_headers, + &Method::POST, + "/api/stop_game", + b"", + true, + ) + .is_err() + ); + } + #[test] fn signatures_bind_the_exact_request_and_nonce() { let key = KeyPair::generate_for(&PKCS_ED25519).unwrap(); @@ -1013,22 +1276,25 @@ mod tests { let snapshot = super::snapshot_event(&state); assert_eq!(snapshot.sequence, 0); assert_eq!(snapshot.event, "snapshot"); - let body = br#"{"username":"player1"}"#; + let user_key = KeyPair::generate_for(&PKCS_ED25519).unwrap(); + let body = serde_json::to_vec(&serde_json::json!({ + "username": "player1", + "public_key": BASE64.encode(user_key.public_key_raw()), + })) + .unwrap(); let headers = signed_headers( &admin_id, &admin_key, &Method::POST, "/api/create_user", - body, + &body, "admin-create-1", ); - let Json(identity) = - create_user(State(state.clone()), headers, Bytes::copy_from_slice(body)) - .await - .unwrap(); + let Json(identity) = create_user(State(state.clone()), headers, Bytes::from(body)) + .await + .unwrap(); - assert!(identity.certificate_pem.contains("BEGIN CERTIFICATE")); - let user_key = KeyPair::from_pem(&identity.private_key_pem).unwrap(); + assert_eq!(identity.username, "player1"); let upload_body = b"signed wasm bytes"; let user_headers = signed_headers( &identity.client_id, diff --git a/web/admin.html b/web/admin.html new file mode 100644 index 0000000..4d14cd5 --- /dev/null +++ b/web/admin.html @@ -0,0 +1,109 @@ + + + + + + + Wolloseum | Administration + + + + + +
      +
      + W WOLLOSEUM + +
      +
      + + +
      +
      WOLLOSEUM | ADMINPrivileged actions require an admin identity
      +
      + + + \ No newline at end of file diff --git a/web/app.css b/web/app.css new file mode 100644 index 0000000..692e40a --- /dev/null +++ b/web/app.css @@ -0,0 +1,630 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap'); + +:root { + color-scheme: light; + --ink: #192825; + --muted: #66736e; + --line: #d9e1dc; + --paper: #f4f6f1; + --white: #fffefa; + --green: #176b50; + --green-dark: #124d3d; + --lime: #d5ef78; + --coral: #cf624c; + --blue: #477b8b; + --mono: 'DM Mono', ui-monospace, monospace; + --sans: 'Manrope', sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background-color: var(--paper); + background-image: radial-gradient(#dce4dc 0.7px, transparent 0.7px); + background-size: 18px 18px; + font: 15px/1.55 var(--sans); +} + +button, +input, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +a { + color: inherit; +} + +.shell { + max-width: 1180px; + margin: 0 auto; + padding: 0 30px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 76px; + border-bottom: 1px solid var(--line); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + font-size: 15px; + font-weight: 800; + letter-spacing: 0; +} + +.brand-mark { + display: grid; + width: 30px; + height: 30px; + place-items: center; + color: var(--lime); + background: var(--green-dark); + border-radius: 8px; + font-size: 17px; +} + +.nav { + display: flex; + align-items: center; + gap: 22px; +} + +.nav a { + color: var(--muted); + text-decoration: none; + font-size: 13px; + font-weight: 700; +} + +.nav a:hover, +.nav a[aria-current="page"] { + color: var(--green); +} + +.eyebrow { + margin: 0 0 10px; + color: var(--green); + font: 500 11px var(--mono); + letter-spacing: 0; + text-transform: uppercase; +} + +.page-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 20px; + padding: 44px 0 28px; +} + +h1 { + margin: 0; + font-size: 36px; + line-height: 1.15; + letter-spacing: 0; +} + +h2 { + margin: 0; + font-size: 17px; + line-height: 1.3; + letter-spacing: 0; +} + +h3 { + margin: 0; + font-size: 13px; + letter-spacing: 0; +} + +.lede { + margin: 10px 0 0; + color: var(--muted); + max-width: 620px; +} + +.mono { + font-family: var(--mono); +} + +.status-chip { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 7px 11px; + color: var(--muted); + background: var(--white); + font: 11px var(--mono); + white-space: nowrap; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #98a39d; +} + +.status-chip.live .status-dot, +.status-chip.running .status-dot { + background: #48a674; + box-shadow: 0 0 0 3px #48a67424; +} + +.status-chip.paused .status-dot, +.status-chip.offline .status-dot { + background: var(--coral); +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 290px; + gap: 26px; + padding-bottom: 60px; +} + +.main-column, +.side-column { + display: grid; + align-content: start; + gap: 20px; +} + +.panel { + border: 1px solid var(--line); + border-radius: 7px; + background: var(--white); + overflow: hidden; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} + +.panel-body { + padding: 20px; +} + +.panel-kicker { + color: var(--muted); + font: 11px var(--mono); +} + +.login-panel { + max-width: 600px; + margin: 28px auto 70px; + border-top: 3px solid var(--green); +} + +.login-panel .panel-body { + padding: 26px; +} + +.login-panel h2 { + font-size: 21px; +} + +.login-note { + margin: 8px 0 22px; + color: var(--muted); + font-size: 13px; +} + +.form-grid { + display: grid; + gap: 14px; +} + +.field { + display: grid; + gap: 7px; +} + +.field label { + font-size: 12px; + font-weight: 800; +} + +.field input, +.field select { + width: 100%; + min-height: 42px; + padding: 9px 11px; + border: 1px solid #cbd5ce; + border-radius: 4px; + color: var(--ink); + background: #fff; + outline: 0; +} + +.field input:focus, +.field select:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px #176b5018; +} + +.field input[type="file"] { + padding: 7px; + font-size: 12px; +} + +.help { + color: var(--muted); + font-size: 11px; +} + +.actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 9px; +} + +.button { + display: inline-flex; + min-height: 40px; + align-items: center; + justify-content: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid var(--green); + border-radius: 4px; + color: white; + background: var(--green); + font-size: 12px; + font-weight: 800; + transition: background 120ms ease, transform 120ms ease; +} + +.button:hover { + background: var(--green-dark); + transform: translateY(-1px); +} + +.button.secondary { + color: var(--ink); + border-color: var(--line); + background: white; +} + +.button.secondary:hover { + background: #f2f5f0; +} + +.button.danger { + border-color: #b64e3d; + background: #b64e3d; +} + +.button:disabled { + cursor: wait; + opacity: .55; + transform: none; +} + +.icon-button { + width: 36px; + min-height: 36px; + padding: 0; +} + +.message { + min-height: 20px; + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.message.error { + color: #a43d30; +} + +.message.success { + color: var(--green); +} + +.stat-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.stat { + min-height: 105px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 5px; + background: #fbfcf8; +} + +.stat-label { + color: var(--muted); + font: 10px var(--mono); + text-transform: uppercase; +} + +.stat-value { + margin-top: 14px; + font-size: 19px; + font-weight: 800; +} + +.stat-value.small { + overflow-wrap: anywhere; + font: 11px/1.5 var(--mono); +} + +.stack { + display: grid; + gap: 15px; +} + +.inline-form { + display: flex; + gap: 9px; + align-items: end; +} + +.inline-form .field { + flex: 1; +} + +.user-list { + display: grid; +} + +.user-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 14px 20px; + border-bottom: 1px solid #e8ece7; +} + +.user-row:last-child { + border-bottom: 0; +} + +.user-name { + font-weight: 800; +} + +.user-id { + max-width: 490px; + overflow: hidden; + color: var(--muted); + font: 10px var(--mono); + text-overflow: ellipsis; +} + +.user-meta { + margin-top: 3px; + color: var(--muted); + font-size: 11px; +} + +.table-tools { + display: flex; + align-items: center; + gap: 8px; +} + +.table-tools input { + width: 180px; + min-height: 34px; + padding: 6px 9px; + border: 1px solid var(--line); + border-radius: 4px; +} + +.event-list { + display: grid; +} + +.event-row { + display: grid; + grid-template-columns: 68px minmax(120px, 180px) minmax(0, 1fr); + gap: 14px; + padding: 13px 17px; + border-bottom: 1px solid #e8ece7; + animation: arrive 180ms ease-out both; +} + +.event-row:last-child { + border-bottom: 0; +} + +.event-sequence, +.event-time { + color: var(--muted); + font: 10px var(--mono); +} + +.event-name { + color: var(--green-dark); + font: 11px var(--mono); + overflow-wrap: anywhere; +} + +.event-data { + color: #53615c; + font: 10px/1.5 var(--mono); + overflow-wrap: anywhere; +} + +.event-filters { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.event-filters input { + width: min(260px, 100%); + min-height: 35px; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 4px; +} + +.empty { + padding: 25px 20px; + color: var(--muted); + font-size: 12px; +} + +.side-note { + color: var(--muted); + font-size: 12px; +} + +.side-note strong { + color: var(--ink); +} + +.key-id { + display: block; + padding: 10px; + overflow-wrap: anywhere; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--green-dark); + background: #f5f8f2; + font: 10px/1.5 var(--mono); +} + +.hidden { + display: none !important; +} + +.accent-band { + height: 4px; + background: linear-gradient(90deg, var(--green) 0 62%, var(--lime) 62% 82%, #e7a28b 82%); +} + +.viewer-head { + align-items: center; +} + +.viewer-summary { + display: flex; + gap: 20px; + color: var(--muted); + font: 11px var(--mono); +} + +.viewer-summary strong { + color: var(--ink); + font-weight: 500; +} + +footer { + display: flex; + justify-content: space-between; + padding: 16px 0 24px; + border-top: 1px solid var(--line); + color: var(--muted); + font: 10px var(--mono); +} + +@keyframes arrive { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 760px) { + .shell { + padding: 0 17px; + } + + h1 { + font-size: 30px; + } + + .topbar { + min-height: 66px; + } + + .nav { + gap: 13px; + } + + .nav a { + font-size: 11px; + } + + .page-head { + align-items: start; + flex-direction: column; + padding: 33px 0 21px; + } + + .layout { + grid-template-columns: 1fr; + gap: 16px; + } + + .event-row { + grid-template-columns: 52px 1fr; + gap: 5px 10px; + } + + .event-data { + grid-column: 2; + } + + .inline-form { + align-items: stretch; + flex-direction: column; + } + + .user-row { + padding: 13px; + } + + .user-row .button { + min-height: 34px; + padding: 7px 9px; + } + + .viewer-summary { + gap: 12px; + flex-wrap: wrap; + } + + footer { + gap: 10px; + } +} \ No newline at end of file diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..23eea3f --- /dev/null +++ b/web/app.js @@ -0,0 +1,399 @@ +const $ = (selector, root = document) => root.querySelector(selector); +const TOKEN_KEY = `wolloseum.session.${document.body?.dataset.page || 'app'}`; +const encoder = new TextEncoder(); + +function setMessage(element, text, kind = '') { + if (!element) return; + element.textContent = text; + element.className = `message ${kind}`.trim(); +} + +function fromBase64(value) { + return Uint8Array.from(atob(value), character => character.charCodeAt(0)); +} + +function readDerElement(bytes, offset) { + const start = offset; + const tag = bytes[offset++]; + let length = bytes[offset++]; + if (tag === undefined || length === undefined) return null; + if (length & 0x80) { + const lengthBytes = length & 0x7f; + if (!lengthBytes || lengthBytes > 4 || offset + lengthBytes > bytes.length) return null; + length = 0; + for (let index = 0; index < lengthBytes; index++) length = length * 256 + bytes[offset++]; + } + const contentStart = offset; + const end = contentStart + length; + if (end > bytes.length) return null; + return { tag, start, contentStart, end }; +} + +function derLength(length) { + if (length < 0x80) return [length]; + const bytes = []; + while (length) { + bytes.unshift(length & 0xff); + length >>>= 8; + } + return [0x80 | bytes.length, ...bytes]; +} + +function normalizeEd25519Pkcs8(bytes) { + const sequence = readDerElement(bytes, 0); + const version = sequence?.tag === 0x30 && sequence.end === bytes.length + ? readDerElement(bytes, sequence.contentStart) + : null; + if (version?.tag !== 0x02 || version.end !== version.contentStart + 1) return bytes; + if (bytes[version.contentStart] === 0) return bytes; + if (bytes[version.contentStart] !== 1) return bytes; + + const algorithm = readDerElement(bytes, version.end); + const privateKey = algorithm?.tag === 0x30 ? readDerElement(bytes, algorithm.end) : null; + const publicKey = privateKey?.tag === 0x04 ? readDerElement(bytes, privateKey.end) : null; + if (algorithm?.tag !== 0x30 || privateKey?.tag !== 0x04 || publicKey?.tag !== 0x81 || publicKey.end !== sequence.end) { + return bytes; + } + + const body = new Uint8Array(3 + algorithm.end - algorithm.start + privateKey.end - privateKey.start); + body.set([0x02, 0x01, 0x00]); + body.set(bytes.subarray(algorithm.start, algorithm.end), 3); + body.set(bytes.subarray(privateKey.start, privateKey.end), 3 + algorithm.end - algorithm.start); + const encodedLength = derLength(body.length); + const normalized = new Uint8Array(1 + encodedLength.length + body.length); + normalized.set([0x30, ...encodedLength]); + normalized.set(body, 1 + encodedLength.length); + return normalized; +} + +function toBase64(value) { + return btoa(String.fromCharCode(...new Uint8Array(value))); +} + +function toPem(label, value) { + const base64 = toBase64(value).match(/.{1,64}/g).join('\n'); + return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`; +} + +async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + const token = localStorage.getItem(TOKEN_KEY); + if (token) headers.set('Authorization', `Bearer ${token}`); + if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json'); + const response = await fetch(path, { ...options, headers }); + if (response.status === 204) return null; + const contentType = response.headers.get('content-type') || ''; + const body = contentType.includes('application/json') ? await response.json() : await response.text(); + if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`); + return body; +} + +async function readCredential(file) { + const text = await file.text(); + try { + const bundle = JSON.parse(text); + if (bundle.client_id && bundle.private_key_pem) return bundle; + } catch (_) { } + const identity = $('#identity-id').value.trim(); + if (!identity) throw new Error('Enter the identity ID printed by the server or supplied by your administrator.'); + return { client_id: identity, private_key_pem: text }; +} + +async function signIn() { + const file = $('#private-key-file').files[0]; + if (!file) throw new Error('Choose your private key file.'); + const credential = await readCredential(file); + const encodedKey = credential.private_key_pem.match(/-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/); + if (!encodedKey) throw new Error('Expected an Ed25519 PKCS#8 private key.'); + const pkcs8 = normalizeEd25519Pkcs8(fromBase64(encodedKey[1].replace(/\s/g, ''))); + let privateKey; + try { + privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']); + } catch (error) { + if (error.name === 'NotSupportedError') { + throw new Error('This browser does not support Ed25519 Web Crypto. Update the browser and try again.'); + } + throw new Error(`Ed25519 key import failed (${error.name}): ${error.message}. Choose data/keys/admin-private.pem or a generated identity JSON bundle.`); + } + const issued = await api('/api/auth/challenge', { + method: 'POST', body: JSON.stringify({ client_id: credential.client_id }), + }); + const challenge = fromBase64(issued.challenge); + let signature; + try { + signature = await crypto.subtle.sign('Ed25519', privateKey, challenge); + } catch (error) { + throw new Error(`Ed25519 challenge signing failed (${error.name}): ${error.message}.`); + } + const session = await api('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ client_id: credential.client_id, challenge: issued.challenge, signature: toBase64(signature) }), + }); + localStorage.setItem(TOKEN_KEY, session.token); + return api('/api/me'); +} + +function logout() { + localStorage.removeItem(TOKEN_KEY); + location.reload(); +} + +function connectEvents(onEvent, onConnection = () => { }) { + let reconnectDelay = 1000; + let socket; + let stopped = false; + const connect = () => { + if (stopped) return; + const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; + socket = new WebSocket(`${scheme}//${location.host}/api/events`); + socket.onopen = () => { reconnectDelay = 1000; onConnection(true); }; + socket.onmessage = message => { + try { onEvent(JSON.parse(message.data)); } catch (_) { } + }; + socket.onclose = () => { + onConnection(false); + if (!stopped) setTimeout(connect, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, 15000); + }; + socket.onerror = () => socket.close(); + }; + connect(); + return () => { stopped = true; socket?.close(); }; +} + +function eventRow(event) { + const row = document.createElement('div'); + row.className = 'event-row'; + const sequence = document.createElement('span'); + sequence.className = 'event-sequence'; + sequence.textContent = `#${event.sequence}`; + const name = document.createElement('span'); + name.className = 'event-name'; + name.textContent = event.event; + const data = document.createElement('span'); + data.className = 'event-data'; + data.textContent = JSON.stringify(event.data ?? {}); + row.append(sequence, name, data); + return row; +} + +function bootViewer() { + const list = $('#event-list'); + const empty = $('#event-empty'); + const status = $('#socket-status'); + const sequence = $('#latest-sequence'); + const total = $('#event-count'); + const search = $('#event-search'); + let count = 0; + const events = []; + const redraw = () => { + list.replaceChildren(); + const query = search.value.trim().toLowerCase(); + const visible = events.filter(event => `${event.event} ${JSON.stringify(event.data)}`.toLowerCase().includes(query)); + empty.classList.toggle('hidden', visible.length > 0); + visible.forEach(event => list.append(eventRow(event))); + }; + search.addEventListener('input', redraw); + connectEvents(event => { + count += 1; + events.unshift(event); + if (events.length > 250) events.pop(); + sequence.textContent = String(event.sequence); + total.textContent = String(count); + redraw(); + }, connected => { + status.classList.toggle('live', connected); + status.classList.toggle('offline', !connected); + $('#socket-label').textContent = connected ? 'LIVE' : 'RECONNECTING'; + }); +} + +function bootLogin() { + const form = $('#login-form'); + const panel = $('#login-panel'); + const message = $('#login-message'); + const page = document.body.dataset.page; + const app = page === 'admin' ? $('#admin-app') : $('#user-app'); + const account = $('#account-name'); + const reveal = async identity => { + const requiredAdmin = page === 'admin'; + if (identity.is_admin !== requiredAdmin) { + throw new Error(requiredAdmin ? 'This key does not belong to an administrator.' : 'Use the administration page to sign in with an admin key.'); + } + panel.classList.add('hidden'); + app.classList.remove('hidden'); + account.textContent = identity.username; + if (requiredAdmin) await bootAdmin(identity); + else await bootUser(identity); + }; + form.addEventListener('submit', async event => { + event.preventDefault(); + const button = $('button[type="submit"]', form); + button.disabled = true; + setMessage(message, 'Verifying challenge...'); + try { await reveal(await signIn()); } + catch (error) { setMessage(message, error.message, 'error'); } + finally { button.disabled = false; } + }); + $('#logout-button, #admin-logout').addEventListener('click', logout); + $('#private-key-file').addEventListener('change', async event => { + const file = event.currentTarget.files[0]; + if (!file || !file.name.endsWith('.json')) return; + try { + const bundle = JSON.parse(await file.text()); + if (bundle.client_id) $('#identity-id').value = bundle.client_id; + } catch (_) { } + }); + if (localStorage.getItem(TOKEN_KEY)) { + api('/api/me').then(reveal).catch(() => localStorage.removeItem(TOKEN_KEY)); + } +} + +async function bootUser(identity) { + $('#player-id').textContent = identity.client_id; + $('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded'; + const status = await api('/api/status'); + paintGameStatus(status.running); + $('#agent-form').addEventListener('submit', async event => { + event.preventDefault(); + const file = $('#agent-file').files[0]; + if (!file) return setMessage($('#agent-message'), 'Choose a compiled WASM module.', 'error'); + const button = $('button[type="submit"]', event.currentTarget); + button.disabled = true; + setMessage($('#agent-message'), `Uploading ${file.name}...`); + try { + const result = await api('/api/update_agent', { method: 'POST', body: await file.arrayBuffer() }); + $('#agent-digest').textContent = result.sha256; + setMessage($('#agent-message'), `Agent accepted | ${result.bytes.toLocaleString()} bytes`, 'success'); + } catch (error) { setMessage($('#agent-message'), error.message, 'error'); } + finally { button.disabled = false; } + }); + connectEvents(event => { + if (event.event === 'snapshot' || event.event === 'game_state_changed') paintGameStatus(event.data.running); + }); +} + +function paintGameStatus(running) { + const chip = $('#game-status'); + if (!chip) return; + chip.classList.toggle('running', running); + chip.classList.toggle('paused', !running); + $('#game-status-label').textContent = running ? 'RUNNING' : 'PAUSED'; + const copy = $('#game-status-copy'); + if (copy) copy.textContent = running ? 'RUNNING' : 'PAUSED'; +} + +async function bootAdmin() { + const status = await api('/api/status'); + paintGameStatus(status.running); + $('#game-control').addEventListener('click', async () => { + const running = $('#game-status-label').textContent === 'RUNNING'; + const button = $('#game-control'); + button.disabled = true; + try { + await api(running ? '/api/stop_game' : '/api/start_game', { method: 'POST', body: '' }); + paintGameStatus(!running); + button.textContent = running ? 'Resume simulation' : 'Pause simulation'; + } catch (error) { setMessage($('#admin-message'), error.message, 'error'); } + finally { button.disabled = false; } + }); + $('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation'; + $('#user-form').addEventListener('submit', createUser); + $('#core-form').addEventListener('submit', uploadCore); + await refreshUsers(); + connectEvents(event => { + if (event.event === 'snapshot' || event.event === 'game_state_changed') { + paintGameStatus(event.data.running); + $('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation'; + } + if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'agent_uploaded') refreshUsers(); + }); +} + +async function createUser(event) { + event.preventDefault(); + const username = $('#new-username').value.trim(); + const button = $('button[type="submit"]', event.currentTarget); + const downloadLink = $('#identity-download'); + if (downloadLink.dataset.objectUrl) URL.revokeObjectURL(downloadLink.dataset.objectUrl); + delete downloadLink.dataset.objectUrl; + downloadLink.removeAttribute('href'); + downloadLink.classList.add('hidden'); + button.disabled = true; + setMessage($('#create-user-message'), 'Generating identity keypair...'); + try { + const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); + const publicKey = await crypto.subtle.exportKey('raw', pair.publicKey); + const privateKey = await crypto.subtle.exportKey('pkcs8', pair.privateKey); + const identity = await api('/api/create_user', { + method: 'POST', + body: JSON.stringify({ username, public_key: toBase64(publicKey) }), + }); + const bundle = JSON.stringify({ username, client_id: identity.client_id, private_key_pem: toPem('PRIVATE KEY', privateKey) }, null, 2); + downloadLink.dataset.objectUrl = URL.createObjectURL(new Blob([bundle], { type: 'application/json' })); + downloadLink.href = downloadLink.dataset.objectUrl; + downloadLink.download = `${username}-identity.json`; + downloadLink.classList.remove('hidden'); + $('#new-username').value = ''; + setMessage($('#create-user-message'), `Created ${username}. Download the private-key bundle before closing this page.`, 'success'); + await refreshUsers(); + } catch (error) { setMessage($('#create-user-message'), error.message, 'error'); } + finally { button.disabled = false; } +} + +async function refreshUsers() { + try { + const users = await api('/api/users'); + const list = $('#user-list'); + list.replaceChildren(); + users.forEach(user => { + const row = document.createElement('div'); + row.className = 'user-row'; + const info = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'user-name'; + name.textContent = `${user.username}${user.is_admin ? ' | admin' : ''}`; + const id = document.createElement('div'); + id.className = 'user-id'; + id.textContent = user.client_id; + const meta = document.createElement('div'); + meta.className = 'user-meta'; + meta.textContent = user.has_agent ? `Agent | ${user.agent_sha256 || 'uploaded'}` : 'No agent'; + info.append(name, id, meta); + row.append(info); + if (!user.is_admin) { + const remove = document.createElement('button'); + remove.className = 'button danger'; + remove.type = 'button'; + remove.textContent = 'Remove'; + remove.addEventListener('click', async () => { + if (!confirm(`Remove ${user.username} and their uploaded agent?`)) return; + try { + await api('/api/delete_user', { method: 'POST', body: JSON.stringify({ client_id: user.client_id }) }); + await refreshUsers(); + } catch (error) { setMessage($('#admin-message'), error.message, 'error'); } + }); + row.append(remove); + } + list.append(row); + }); + $('#user-count').textContent = String(users.filter(user => !user.is_admin).length); + } catch (error) { setMessage($('#admin-message'), error.message, 'error'); } +} + +async function uploadCore(event) { + event.preventDefault(); + const file = $('#core-file').files[0]; + if (!file) return setMessage($('#core-message'), 'Choose a WASM core module.', 'error'); + const button = $('button[type="submit"]', event.currentTarget); + button.disabled = true; + setMessage($('#core-message'), `Validating ${file.name}...`); + try { + const result = await api('/api/upload_core', { method: 'POST', body: await file.arrayBuffer() }); + setMessage($('#core-message'), `Core module accepted | ${result.sha256}`, 'success'); + } catch (error) { setMessage($('#core-message'), error.message, 'error'); } + finally { button.disabled = false; } +} + +if (document.body.dataset.page === 'viewer') bootViewer(); +else bootLogin(); \ No newline at end of file diff --git a/web/user.html b/web/user.html new file mode 100644 index 0000000..f781b3f --- /dev/null +++ b/web/user.html @@ -0,0 +1,94 @@ + + + + + + + Wolloseum | Player + + + + + +
      +
      + W WOLLOSEUM + +
      +
      + + +
      +
      WOLLOSEUM | PLAYEREd25519 identity
      +
      + + + \ No newline at end of file diff --git a/web/viewer.html b/web/viewer.html new file mode 100644 index 0000000..050911d --- /dev/null +++ b/web/viewer.html @@ -0,0 +1,44 @@ + + + + + + + Wolloseum | Live viewer + + + + + +
      +
      + W WOLLOSEUM + +
      +
      +
      +
      +

      Public stream | websocket

      +

      World activity

      +

      A live, ordered view of server state changes.

      +
      + CONNECTING +
      +
      +
      +
      Latest -Received 0
      +
      +
      +
      +
      Waiting for the first event...
      +
      +
      + +
      + + + \ No newline at end of file