diff --git a/README.md b/README.md index 5b7dd5d..07e91cd 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,32 @@ Both peers must use matching encryption/geofence or audio fails silently. `enabl ## How It Works +### Astation connections + +Atem tries a configured direct endpoint first and then the identity relay after +it has learned the target Astation identity. Direct and relay connections use +the same device session, so one Atem can move between networks without pairing +again. Successful authentication stores `astation_relay_code` automatically; +manual configuration is only needed for an override or headless provisioning. + +| Atem location | Endpoint | First connection | Internet required | +|---------------|----------|------------------|-------------------| +| Same Mac | `ws://127.0.0.1:8080/ws` | Transparent same-user proof | No | +| Another LAN machine | `ws://:8080/ws` | Approve pairing in Astation | No | +| Remote network | Public WSS identity relay | Approve pairing in Astation | Yes | + +All paths use device authentication v2. Astation sends a fresh challenge; Atem +proves possession of the local bootstrap token or its saved device-session token +with HMAC-SHA256. A session ID by itself is not accepted. Same-Mac operation +continues when Wi-Fi is disabled, and LAN operation needs no relay once the IP is +configured. + +Direct LAN currently uses plaintext `ws://`. Authentication prevents a stolen +session ID from being sufficient, but it does not stop traffic inspection or an +active LAN attacker during initial pairing. Treat direct LAN as pre-production +until WSS certificate pinning is implemented. The wire contract and test matrix +are documented in [`designs/session-auth.md`](designs/session-auth.md). + ### TUI Modes | Mode | Description | @@ -223,6 +249,7 @@ Files in `~/.config/atem/`: | `convo.toml` | ConvoAI agent config (API keys, provider params) | None (chmod 0600) | | `credentials.enc` | SSO tokens | AES-256-GCM (machine-bound) | | `project_cache.enc` | Project list + active project selection | AES-256-GCM (machine-bound) | +| `sessions.json` | Per-Astation device session IDs and tokens | Plaintext (chmod 0600) | Encrypted files are bound to the machine they were created on — copying them to another machine won't decrypt. @@ -233,6 +260,7 @@ Encrypted files are bound to the machine they were created on — copying them t ```toml # astation_ws = "ws://127.0.0.1:8080/ws" # astation_relay_url = "https://station.agora.build" +# astation_relay_code = "astation-..." # learned automatically after authentication # bff_url = "https://agora-cli.agora.io" # sso_url = "https://sso2.agora.io" @@ -245,6 +273,9 @@ Encrypted files are bound to the machine they were created on — copying them t ```bash ATEM_BFF_URL=... # Override BFF API base URL ATEM_SSO_URL=... # Override SSO base URL +ASTATION_WS=... # Direct loopback, LAN, or VPN WebSocket endpoint +ASTATION_RELAY_URL=... # Relay HTTP(S) base URL +ASTATION_RELAY_CODE=... # Target Astation identity room AGORA_APP_ID=... # Override active project App ID AGORA_APP_CERTIFICATE=... # Override active project certificate ``` diff --git a/configs/config.example.toml b/configs/config.example.toml index bc991a5..731d3d1 100644 --- a/configs/config.example.toml +++ b/configs/config.example.toml @@ -7,6 +7,10 @@ # ── Astation ───────────────────────────────────────────────────────── # astation_ws = "ws://127.0.0.1:8080/ws" # astation_relay_url = "https://station.agora.build" +# astation_relay_code = "astation-..." # learned automatically after authentication +# +# For an offline LAN connection, point astation_ws at the Mac running Astation: +# astation_ws = "ws://192.168.1.20:8080/ws" # ── Diagram hosting server ─────────────────────────────────────────── # diagram_server_url = "http://localhost:8787" diff --git a/designs/connection-priority.md b/designs/connection-priority.md index f5ad0f9..4559d61 100644 --- a/designs/connection-priority.md +++ b/designs/connection-priority.md @@ -1,147 +1,57 @@ -# Connection Priority Architecture +# Astation Connection Priority -## Overview +Status: implemented. -Atem now uses a clear, simple priority cascade for connecting to Astation: +## Order -``` -1. Local URL (configurable) ← Same machine / LAN / VPN -2. Relay (wss://station.agora.build/ws) ← Remote connection -``` - -The local URL can be configured to support: -- **Same machine**: `ws://127.0.0.1:8080/ws` (default) -- **LAN**: `ws://192.168.1.5:8080/ws` (auto-detected by Astation) -- **VPN**: `ws://100.x.x.x:8080/ws` (Netbird, Tailscale, ZeroTier, etc.) - -## Changes Made - -### Astation (Server) - -**Listen on all interfaces:** -- Changed from `127.0.0.1` to `0.0.0.0` so LAN clients can connect -- Now accessible from: - - Same machine: `ws://127.0.0.1:8080/ws` - - LAN: `ws://:8080/ws` (e.g., `ws://192.168.1.5:8080/ws`) - - Remote: via relay server only - -**UI Updates:** -- Settings window now shows detected local network IP addresses -- Astation always listens on all interfaces (`0.0.0.0:8080`) -- Only Station relay URL needs configuration -- Status shows: "Listening on: ws://127.0.0.1:8080/ws, ws://192.168.1.5:8080/ws" -- Info text explains VPN IP configuration for Atem - -**Files Modified:** -- `Sources/Menubar/AstationApp.swift` - Listen on `0.0.0.0`, added `getLocalNetworkIP()` -- `Sources/Menubar/SettingsWindowController.swift` - Updated UI, added server status display - -### Atem (Client) +1. Connect to `astation_ws`, defaulting to `ws://127.0.0.1:8080/ws`. +2. Complete device authentication v2 on that direct socket. +3. If direct connection fails and an Astation identity was learned previously, + connect to that identity room at `astation_relay_url`. +4. Send `hello`, receive Astation's challenge through the relay, and complete + the same v2 session proof or pairing flow. +5. If both paths fail, continue without Astation and retry later. -**Connection Priority (simplified):** +There is no separate session-ID URL attempt. `connect_with_session` is a +compatibility alias; authentication always happens after the WebSocket opens. -```rust -// 1. Try configured local URL with session (if available) -config.astation_ws()?session= -// Default: ws://127.0.0.1:8080/ws?session= -// VPN: ws://100.x.x.x:8080/ws?session= - -// 2. Try configured local URL direct -config.astation_ws() -// Default: ws://127.0.0.1:8080/ws -// VPN: ws://100.x.x.x:8080/ws - -// 3. Try relay with session (if available) -wss://station.agora.build/ws?session= - -// 4. Try relay with pairing code -wss://station.agora.build/ws?role=atem&code= -``` - -**Configuration:** - -For VPN connections (Netbird, Tailscale, ZeroTier), configure the VPN IP in `~/.config/atem/config.toml`: - -```toml -astation_ws = "ws://100.64.0.2:8080/ws" # Netbird IP -# or -astation_ws = "ws://100.100.100.5:8080/ws" # Tailscale IP -``` +## Configuration -For custom relay servers: ```toml -astation_relay_url = "http://100.117.91.44:8080" # Custom relay -# HTTP/HTTPS base URL - automatically converted to ws:// or wss:// -``` - -Or via environment variables: -```bash -export ASTATION_WS="ws://100.64.0.2:8080/ws" -export ASTATION_RELAY_URL="http://100.117.91.44:8080" -``` - -**Benefits:** -- Always tries configured local URL first (lowest latency, most stable) -- Supports VPN IPs that Astation can't auto-detect -- Falls back to relay only when needed -- Session-based auth is seamless (no pairing code needed) -- Pairing code is last resort for explicit approval +# Same Mac, also the default +astation_ws = "ws://127.0.0.1:8080/ws" -**Files Modified:** -- `src/app.rs` - Refactored `spawn_astation_connect()` to use `config.astation_ws()` -- `src/config.rs` - Already supports `astation_ws` configuration - -## Connection Scenarios - -### Same Machine -``` -Atem → ws://127.0.0.1:8080/ws → Astation -✅ Direct, fast, no auth needed -✅ Default configuration (no setup required) -``` +# Or direct LAN/VPN +# astation_ws = "ws://192.168.1.20:8080/ws" +# astation_ws = "ws://100.64.0.20:8080/ws" -### Same LAN (Different Machines) +# Remote fallback +astation_relay_url = "https://station.agora.build" +astation_relay_code = "astation-..." ``` -Atem → ws://192.168.1.5:8080/ws → Astation -✅ Direct, fast, no relay needed -✅ Configure astation_ws = "ws://192.168.1.5:8080/ws" in Atem config -``` - -### VPN (Netbird, Tailscale, ZeroTier) -``` -Atem → ws://100.64.0.2:8080/ws → Astation (via VPN tunnel) -✅ Direct through VPN, no relay needed -✅ Configure astation_ws = "ws://:8080/ws" in Atem config -✅ Astation listens on 0.0.0.0 so VPN interface is accessible -``` - -### Different Networks (Remote, No VPN) -``` -Atem → wss://station.agora.build/ws → Relay → Astation -✅ Via relay server, pairing code or session auth -✅ Fallback when direct connection fails -``` - -## Security Model -1. **Local connections** - Trusted (localhost or LAN) -2. **Session-based** - After HTTP auth, 30-day TTL -3. **Pairing code** - Explicit approval, short-lived (5 minutes) +Environment overrides are `ASTATION_WS`, `ASTATION_RELAY_URL`, and +`ASTATION_RELAY_CODE`. Successful authentication persists the relay code +automatically; the explicit value is an override for provisioning or recovery. -## Testing +## Network behavior -**Astation:** -- Run on macOS: `swift build && .build/debug/Astation` -- Check logs for network IP: "Network: ws://192.168.1.x:8080" -- Open Settings → Server Info → verify IP displayed +| Scenario | Direct | Relay | Result | +|----------|--------|-------|--------| +| Same Mac, radios disabled | Loopback succeeds | Not used | Fully offline | +| Separate machine, same LAN, internet down | LAN IP succeeds | Not used | Fully offline after local routing works | +| Separate networks | Direct normally fails | WSS succeeds | Internet and relay required | +| Direct and relay both reachable | Direct wins | Standby fallback | Lowest-latency path | -**Atem:** -- Run: `cargo run` -- Watch connection attempts in status bar -- Verify local connection tried first (check Astation logs) -- Disconnect Astation → verify relay fallback works +Direct LAN clients still require first-use approval and later HMAC proofs. LAN +reachability never grants the loopback policy. See `session-auth.md` for the +protocol and the current plaintext-LAN limitation. -## Version +## Operational checks -- Atem: v0.4.27 (pending release) -- Astation: v0.4.13 (pending release) +- Disable Wi-Fi on the Astation Mac and verify the default loopback connection. +- Configure a real LAN address from a second host and verify operation with the + internet uplink unavailable. +- Stop the direct listener and verify identity-relay fallback. +- Restore direct service and verify the same Astation session is reused. +- Confirm an invalid saved proof falls back to pairing without reconnecting. diff --git a/designs/relay-support.md b/designs/relay-support.md index b2dbb4d..d703527 100644 --- a/designs/relay-support.md +++ b/designs/relay-support.md @@ -1,214 +1,70 @@ -# ✅ Relay Server Support - COMPLETE +# Astation Identity Relay -## Summary +Status: device authentication v2 is implemented for Atem identity-room clients. +The relay still has production security blockers listed below. -Full universal session support for relay connections is now **COMPLETE**! +## Roles -The solution uses the **astation_id as the room code**, enabling session-based auth through the relay with **zero relay server changes**. +- Astation connects to `role=astation` using its stable identity as the room. +- Atem connects to `role=atem` with the target `astation_relay_code` and its + stable, percent-encoded `atem_id`. +- The relay routes per-Atem envelopes between the room owner and each client. +- Astation remains the device-authentication authority. ---- +## Reconnect flow -## How It Works - -### Architecture - -``` -Atem → Relay → Astation - -Both connect to relay using astation_id as the room code: -- Astation: ?role=astation&code= -- Atem: ?role=atem&code= - -Relay creates room and forwards all messages bidirectionally. -Session auth happens via WebSocket messages (transparent to relay). -``` - -### Flow - -**1. Astation connects to relay:** -``` -wss://station.agora.build/ws?role=astation&code=astation-abc123... +```text +Atem -> relay: connect to identity room, then hello +Atem <- relay <- Astation: auth_required {challenge, astation_id, protocol=2} +Atem -> relay -> Astation: auth {session_id, atem_id, proof} +Atem <- relay <- Astation: authenticated ``` -- Uses its own identity as the room code -- Relay creates/joins room "astation-abc123..." -**2. Atem connects to relay:** -``` -wss://station.agora.build/ws?role=atem&code=astation-abc123... -``` -- Uses the target Astation's ID (from config) as room code -- Relay pairs them in the same room -- Messages forwarded bidirectionally - -**3. Session auth via messages:** -``` -Atem → Relay → Astation: { status: "auth_required", astation_id: "..." } -Atem ← Relay ← Astation: { status: "auth", session_id: "..." } -Atem → Relay → Astation: Session validated → authenticated -``` -- Auth happens end-to-end -- Relay just forwards messages -- Universal session system works transparently +The relay must not interpret `hello` or a session ID as authorization. It binds +a pending session claim only after observing Astation's authenticated/granted +response. Until that point Astation rejects application messages and does not +send account credentials. ---- +When Atem has no valid session, the same socket carries an eight-digit pairing +code. Astation shows the device and code for approval, then returns a new token +through the WSS connection. ## Configuration -### Atem (`~/.config/atem/config.toml`) - ```toml -# Local/VPN connection -astation_ws = "ws://127.0.0.1:8080/ws" - -# Relay connection astation_relay_url = "https://station.agora.build" -astation_relay_code = "astation-abc123-def456..." # The Astation's identity +astation_relay_code = "astation-..." ``` -**Get the Astation ID:** -- On macOS: `cat ~/Library/Application\ Support/Astation/identity.txt` -- Or: Check Astation settings UI (shows identity) - -### Astation - -**No config changes needed!** -- Astation already has its persistent identity -- Just needs to connect to relay with its identity as code -- (This would be implemented in Astation's relay client) - ---- - -## Connection Priority - -With the new configuration, Atem tries connections in this order: - -1. **Local WebSocket** (`astation_ws`) - - Try with session auth → auto-authenticated if session valid - - Try without auth → works for localhost - -2. **Relay with astation_id** (`astation_relay_code` configured) - - Connect to relay room using astation_id - - Session auth via messages → auto-authenticated if session valid - - Fallback to pairing if session expired → user approves → new session saved - -3. **Legacy relay pairing** (no `astation_relay_code`) - - Old flow: register for pairing code, show code to user - - Still works for backward compatibility - ---- - -## Benefits - -✅ **Universal sessions work through relay** - Same session for local and relay -✅ **No re-pairing when switching** - Local fails → relay takes over seamlessly -✅ **Zero relay changes** - Relay is a dumb pipe, just forwards messages -✅ **Simple configuration** - Just add astation_relay_code to config -✅ **Backward compatible** - Old pairing flow still works as fallback - ---- - -## Testing Checklist - -- [ ] Get Astation identity: `cat ~/Library/Application\ Support/Astation/identity.txt` -- [ ] Add to Atem config: `astation_relay_code = ""` -- [ ] Test local connection: Works ✅ (already tested) -- [ ] Test relay connection: Atem connects via relay using astation_id as code -- [ ] Test session through relay: Auto-authenticated (no pairing) -- [ ] Test pairing through relay: User approves → new session saved -- [ ] Test endpoint switching: Local → Relay without re-pairing - ---- - -## Implementation Details - -### Files Modified - -**Atem:** -- `src/config.rs`: Added `astation_relay_code` field + env var support -- `src/app.rs`: Updated relay connection to use astation_id as code -- `configs/config.example.toml`: Documented new config option -- `designs/relay-support.md`: This file - -**Astation:** -- `AstationWebSocketServer.swift`: Handles session verification requests -- `relay-server/src/session_verify.rs`: Caching infrastructure (for future use) -- `relay-server/src/main.rs`: Added SessionVerifyCache to AppState - -**Relay Server:** -- No changes needed! Uses existing room-based pairing with astation_id as code - -### Code Example (Atem) - -```rust -// app.rs - Relay connection logic -if let Some(astation_id) = config.astation_relay_code.as_ref() { - // Use astation_id as the room code - let relay_url = format!("{}/ws?role=atem&code={}", relay_ws_url, astation_id); - let mut client = AstationClient::new(); - if let Ok(()) = client.connect(&relay_url).await { - // Session auth happens via WebSocket messages (authenticate() called in connect()) - return Ok(client); - } -} -``` - -### Code Example (Astation - Future) - -```swift -// Connect to relay using own identity as room code -let relayUrl = "wss://station.agora.build/ws?role=astation&code=\(AstationIdentity.shared.id)" -// Then just forward messages as usual -``` - ---- - -## Remaining Work - -### Astation Relay Connection - -**TODO:** Implement Astation → Relay connection using astation_id as code. - -Currently Astation only runs a local WebSocket server. To support relay, it needs to: -1. Connect to relay: `wss://station.agora.build/ws?role=astation&code=` -2. Forward messages from relay to local clients (and vice versa) -3. Handle both local and relay connections simultaneously - -**Estimated time:** 2-4 hours - -**Files to modify:** -- Create `AstationRelayClient.swift` (similar to local WebSocket client) -- Update `AstationHubManager.swift` to manage both local and relay connections -- Add relay toggle in Settings UI - -**Not blocking Atem:** Atem side is complete and ready to use relay with astation_id! - ---- - -## Verification Infrastructure (Bonus) - -The session verification infrastructure (SessionVerifyCache, verification protocol) is implemented but not currently used since the relay acts as a pure proxy. +The identity code is learned and persisted after successful authentication. It +is an Astation routing identifier, not an authentication secret; a saved v2 +session is still required for automatic reconnect. -**Future use cases:** -- Relay-side session enforcement (rate limiting per session) -- Session analytics (track relay usage per Astation) -- Multi-hop relay chains (relay → relay → Astation) +## Pairing rooms ---- +The legacy `/api/pair` room remains available for discovering/connecting an +Astation. Identity rooms are used for persistent reconnect after Atem knows the +target `astation_id`. Device proof is required after either transport connects. -## Summary +## Production blockers -**Status:** ✅ **COMPLETE** (Atem side) +1. Authenticate `role=astation` before creating or replacing an identity-room + owner. A stable room code alone does not establish ownership. +2. Require authenticated device context on Voice, LLM, Vault, and RTC owner + APIs rather than trusting a bare session ID. +3. Make disconnect/replacement cleanup connection-generation aware so an old + socket cannot remove its replacement. +4. Apply explicit WebSocket admission, message-size, and message-rate limits. +5. Add device revocation and session rotation controls. -Atem can now: -- Connect to relay using astation_id as room code -- Use universal sessions through relay -- No re-pairing when switching local ↔ relay -- Automatic fallback to pairing if session expired +These blockers mean the relay should not yet be described as a complete +production authorization boundary, even though the Atem-to-Astation v2 proof is +implemented. -**Next step:** Implement Astation → Relay connection (separate task) +## Verification -**Ready to deploy:** Yes! Atem universal sessions work end-to-end: -- Local ✅ -- LAN ✅ -- VPN ✅ -- Relay ✅ (pending Astation relay client) +- Confirm `hello` only triggers an Astation challenge. +- Confirm application messages before proof are rejected. +- Confirm a valid LAN-created session authenticates through the identity relay. +- Confirm an invalid proof falls back to pairing on the same socket. +- Confirm two Atem IDs remain independently connected in one Astation room. diff --git a/designs/session-auth.md b/designs/session-auth.md index f0349d9..09a81dc 100644 --- a/designs/session-auth.md +++ b/designs/session-auth.md @@ -1,300 +1,137 @@ -# Session-Based Pairing Authentication Implementation - -## Overview - -Implemented a comprehensive pairing + session system for secure Atem↔Astation connections: -- **Pairing required** for all connections (local/LAN/VPN/relay) -- **Session persistence** after pairing (7-day inactivity expiry) -- **Activity refresh** on every connection/message -- **Multi-device support** with independent sessions - -## Implementation Status - -### ✅ COMPLETED: Atem (Rust) - -#### 1. Session Storage (`src/auth.rs`) -- Changed `authenticated_at` → `last_activity` -- Changed expiry from 30 days → 7 days -- Added `refresh()` method to update activity timestamp -- Added `age_seconds()` to check session age -- Added `new()` constructor -- Added `now_timestamp()` helper - -**Tests Added (8 tests, all passing):** -- `session_is_valid_when_fresh` ✅ -- `session_expires_after_7_days` ✅ -- `session_valid_just_before_expiry` ✅ -- `session_refresh_extends_validity` ✅ -- `session_refresh_prevents_expiry` ✅ -- `session_age_calculation` ✅ -- `session_save_and_load_preserves_activity` ✅ -- `multiple_sessions_independent` ✅ - -#### 2. Connection Logic (`src/app.rs`) -- `poll_astation_connect()`: Refreshes session on successful connection -- `process_astation_messages()`: Refreshes session when messages received -- Session refresh saves to `~/.config/atem/session.json` automatically - -#### 3. HTTP→WebSocket Conversion (`src/app.rs`) -- Converts `http://` → `ws://` and `https://` → `wss://` for relay URLs -- Supports custom relay servers (e.g., `http://100.117.91.44:8080`) - -### ✅ COMPLETED: Astation (Swift) - -#### 1. Session Storage (`SessionStore.swift` - NEW FILE) -**Features:** -- Thread-safe session storage (DispatchQueue with barrier) -- Persists to disk (`~/Library/Application Support/Astation/sessions.json`) -- 7-day inactivity expiry -- Secure token generation (SecRandom) -- Auto-cleanup of expired sessions - -**Methods:** -- `validate(sessionId:) -> Bool` - Check if session valid -- `refresh(sessionId:)` - Update last activity timestamp -- `create(hostname:) -> SessionInfo` - Create session after pairing -- `delete(sessionId:)` - Remove session -- `get(sessionId:) -> SessionInfo?` - Get session info -- `getAllActive() -> [SessionInfo]` - List active sessions -- `cleanupExpired()` - Remove expired sessions - -**Testing Helpers (DEBUG only):** -- `createTest()` - Create session with custom parameters -- `count` - Get session count - -#### 2. WebSocket Server Auth (`AstationWebSocketServer.swift`) -**Authentication Flow:** -1. Client connects → Server sends `auth_required` message -2. Client responds with auth message (session ID or pairing code) -3. Server validates: - - **Session auth**: Check `sessionStore.validate()` → auto-approve if valid - - **Pairing auth**: Show dialog → user approves → create new session -4. Authenticated clients added to `authenticatedClients` set -5. Unauthenticated clients rejected (non-auth messages → close connection) - -**Session Refresh:** -- On every message from authenticated client -- Updates `last_activity` in SessionStore - -**Methods Added:** -- `handleAuthMessage()` - Process auth credentials -- `authenticateClient()` - Mark client as authenticated + refresh session -- `showPairingDialog()` - Show macOS alert for pairing approval - -#### 3. Message Protocol (`AstationMessage.swift`) -**Convenience Constructors:** -```swift -.auth(info: [String: String]) // Auth messages -.error(message: String) // Error messages +# Device Authentication v2 + +Status: implemented for direct and identity-relay connections. This document is +the authoritative Atem-side contract. The matching Astation specification is +`docs/specs/2026-07-21-device-authentication-v2.md` in the Astation repository. + +## Connection matrix + +| Path | Endpoint | First connection | Internet required | +|------|----------|------------------|-------------------| +| Same Mac | `ws://127.0.0.1:8080/ws` | Same-user bootstrap proof | No | +| LAN or VPN | `ws://:8080/ws` | User-approved pairing | No | +| Remote | Astation identity room on WSS relay | User-approved pairing | Yes | + +The direct endpoint is attempted before relay. All paths can coexist, and the +same saved device session works for direct LAN and relay reconnects. + +## Trust boundaries + +- Astation determines loopback from the kernel socket peer address. Atem cannot + claim loopback through a header, hostname, or protocol field. +- Loopback skips interactive pairing only when Atem can read Astation's local + bootstrap file and that file has no group or other permission bits. +- LAN, VPN, and relay clients pair once and then prove possession of the saved + session token. A `session_id` alone never authenticates a device. +- The identity relay transports authentication messages. Astation, not the + relay, verifies the proof and decides whether the Atem can send app messages. + +## Challenge and proof + +Astation starts a direct connection, or answers an identity-relay `hello`, with: + +```json +{ + "type": "statusUpdate", + "data": { + "status": "auth_required", + "data": { + "astation_id": "astation-...", + "challenge": "64 lowercase hex characters", + "transport": "loopback|lan|relay", + "protocol": "2" + } + } +} ``` -Uses `.statusUpdate` internally for compatibility. - -### ✅ COMPLETED: Atem Client-Side Auth +The proof is lowercase hexadecimal HMAC-SHA256 over this exact UTF-8 string: -**Implemented auth message flow in Atem:** - -1. **`authenticate()` method** (`src/websocket_client.rs`): - - ✅ Waits for `auth_required` message - - ✅ Tries session auth first (if saved session exists) - - ✅ Falls back to pairing if session invalid/expired - - ✅ Saves new session after successful pairing - -2. **Session auth flow**: - - ✅ Loads session from `~/.config/atem/session.json` - - ✅ Sends `{ status: "auth", session_id: "..." }` - - ✅ Refreshes session on success - - ✅ Falls back to pairing on expiry - -3. **Pairing auth flow**: - - ✅ Generates 8-digit OTP code - - ✅ Displays to user: "🔐 Pairing... Code: 12345678" - - ✅ Sends `{ status: "auth", pairing_code: "...", hostname: "..." }` - - ✅ Waits up to 5 minutes for approval - - ✅ Saves session credentials on success - -**See `designs/session-auth.md` (this file) for full details.** - -### ⚠️ TODO: Relay Server - -The relay server (`relay-server/src/relay.rs`) needs the same session logic: -1. Add `SessionStore` (Rust version) -2. Validate sessions on WebSocket upgrade -3. Support both `?session=` and `?role=X&code=Y` auth -4. Refresh sessions on activity - -## Security Model - -| Connection | Auth Required | Session Saved | Expiry | -|------------|---------------|---------------|--------| -| **Localhost** | ✅ Pairing (first time) | ✅ Yes | 7 days inactivity | -| **LAN** | ✅ Pairing (first time) | ✅ Yes | 7 days inactivity | -| **VPN** | ✅ Pairing (first time) | ✅ Yes | 7 days inactivity | -| **Relay** | ✅ Pairing (first time) | ✅ Yes | 7 days inactivity | - -**Unified security everywhere:** -- All connections require explicit pairing approval (first time) -- Sessions auto-refresh with activity (up to 7 days) -- Expired sessions require re-pairing -- Each device has independent session - -## Testing - -### Atem Tests -```bash -cd /home/guohai/Dev/Agora.Build/Atem -cargo test auth:: -- --nocapture +```text +astation-auth-v2\n\n\n\n ``` -**Result:** ✅ 19 tests passed (including 8 new session tests) +For loopback, the HMAC key is `local-bootstrap-token` and the proof input uses +the literal session ID `local`. For LAN and relay reconnects, the key is the +saved session token and the real session UUID is used. + +The cross-language test vector is: + +| Field | Value | +|-------|-------| +| token | `token-abc` | +| challenge | `challenge-123` | +| astation ID | `astation-home` | +| Atem ID | `atem-office` | +| session ID | `session-456` | +| proof | `9fde5ba861c1a159d377b89e6fb3f92d245795998af958f5db3ad343d589d0ba` | + +## Pairing and reconnect + +Remote first-time pairing has two explicit stages. The short relay-room code lets +Astation and Atem discover each other, then the identity-relay connection shows +an 8-digit device code for approval. `atem pair` reports success only after the +second stage creates and stores the reusable device session. + +Every v2 handshake is bounded both while waiting for `auth_required` and after +the challenge is received. Atem rejects missing or malformed v2 challenges +rather than falling back to bearer-session authentication. + +Background TUI reconnects are proof-only and never open an interactive pairing +prompt. A missing, expired, or revoked session closes that attempt and requires +an explicit `atem pair`. Explicit pairing succeeds only after the new session +token has been written securely to disk. + +1. Atem receives `auth_required` and looks up a session by `astation_id`. +2. When a valid session exists, Atem sends `session_id`, `atem_id`, and `proof`. +3. Astation verifies the proof, expiry, and device binding before registering + the client or sending credentials. +4. If the session is absent, invalid, or expired, Atem sends an eight-digit + pairing code and waits up to five minutes for approval. +5. Astation returns a new session ID and token after approval. Atem persists it + and uses a fresh proof on later connections. + +An invalid or expired saved proof falls back to pairing on the same open socket. +Old clients that send only a session ID cannot authenticate against v2. + +## Local state + +| Path | Mode | Contents | +|------|------|----------| +| `~/.config/atem/sessions.json` | `0600` | Sessions keyed by Astation ID | +| `~/.config/atem/config.toml` | existing user config mode | Stable `instance_id`, `atem_id`, endpoints | +| `~/Library/Application Support/Astation/local-bootstrap-token` | `0600` | Same-user loopback key, read on macOS only | + +The `~/.config/atem` directory is set to `0700` when sessions are saved. Never +print session tokens, proofs, or bootstrap contents. Existing session files are +validated as current-user regular files, changed to `0600` before reading, and +opened without following symbolic links. Private writes apply the same checks +before truncating an existing file. + +## Practical verification -### Astation Tests -**TODO:** Add Swift unit tests for: -- `SessionStore` - create, validate, refresh, expire, cleanup -- `AstationWebSocketServer` - auth flow, session validation, pairing dialog - -## Configuration - -### Atem Config (`~/.config/atem/config.toml`) -```toml -# Local/VPN connection URL -astation_ws = "ws://127.0.0.1:8080/ws" # Default (localhost) -# astation_ws = "ws://192.168.1.5:8080/ws" # LAN IP -# astation_ws = "ws://100.64.0.2:8080/ws" # Netbird VPN - -# Relay server URL (auto-converts http→ws) -astation_relay_url = "http://100.117.91.44:8080" # Custom relay -# astation_relay_url = "https://station.agora.build" # Production (default) -``` - -### Session Storage Locations -- **Atem**: `~/.config/atem/session.json` -- **Astation**: `~/Library/Application Support/Astation/sessions.json` - -## User Flow Examples - -### First Connection (Machine B) -``` -1. User runs: atem -2. Atem connects to Astation -3. Astation shows dialog: "Allow 'machine-b'? Code: 12345678" -4. User clicks "Allow" -5. Astation creates session, sends to Atem -6. Atem saves session to disk -7. Connected! ✅ -``` - -### Subsequent Connections (Machine B) -``` -1. User runs: atem -2. Atem sends session ID -3. Astation validates (< 7 days) → auto-approves -4. Connected! ✅ (no dialog) -``` - -### After 7 Days Idle (Machine B) -``` -1. User runs: atem -2. Atem sends old session ID -3. Astation validates → expired! -4. Astation shows dialog again (new pairing) -5. User clicks "Allow" -6. New session created -7. Connected! ✅ -``` - -### Multiple Devices -``` -Machine B: Session sess-abc (created 2 days ago, active) -Machine C: Session sess-def (created 5 days ago, active) -Laptop: Session sess-xyz (created 8 days ago, expired ❌) - -Each device has independent session. -Activity on Machine B doesn't affect Machine C. -``` - -## Next Steps - -1. **Complete Atem client-side auth** (`src/websocket_client.rs`) - - Send auth message on connection - - Handle auth responses - - Fall back to pairing on session expiry - -2. **Add relay server session support** (`relay-server/src/relay.rs`) - - Port SessionStore to Rust - - Validate sessions on WebSocket upgrade - - Refresh on activity - -3. **Add tests** - - Swift unit tests for SessionStore - - Integration tests for full auth flow - - Test session expiry and refresh - -4. **Documentation** - - Update README with pairing instructions - - Document session management for users - -## Files Changed - -### Atem -- ✅ `src/auth.rs` - Session model + 8 new tests -- ✅ `src/app.rs` - Session refresh on connection/messages -- ✅ `configs/config.example.toml` - VPN + relay examples -- ✅ `designs/connection-priority.md` - Architecture docs - -### Astation -- ✅ `Sources/Menubar/SessionStore.swift` - NEW FILE (session storage) -- ✅ `Sources/Menubar/AstationWebSocketServer.swift` - Auth flow + pairing dialog -- ✅ `Sources/Menubar/AstationMessage.swift` - Auth/error helpers -- ✅ `Sources/Menubar/AstationApp.swift` - Listen on 0.0.0.0 -- ✅ `Sources/Menubar/SettingsWindowController.swift` - Show network IPs - -### Documentation -- ✅ `designs/session-auth.md` - THIS FILE -- ✅ `designs/connection-priority.md` - Updated with VPN support - -## Compilation Status - -**Atem:** ✅ Compiles successfully ```bash -cargo check -# Finished `dev` profile in 0.92s +cargo test websocket_client::tests::device_auth_proof_matches_protocol_vector +cargo test websocket_client::tests::local_bootstrap_token_requires_private_permissions +cargo test websocket_client::tests::practical_websocket_v2_pairing_sends_identity_and_handles_denial +cargo test websocket_client::tests::practical_websocket_background_connect_never_starts_pairing +cargo test websocket_client::tests::practical_websocket_pairing_fails_when_session_cannot_be_persisted +cargo test auth::tests::private_file_write_uses_owner_only_permissions +cargo test -- --test-threads=1 ``` -**Astation:** ⚠️ Not tested (macOS only, Linux build unavailable) - -## Security Considerations - -✅ **Explicit approval required** - User must click "Allow" for every new device -✅ **Session tokens secure** - 64-char hex from SecRandom (256-bit entropy) -✅ **Time-based expiry** - 7 days forces re-approval for inactive devices -✅ **Activity tracking** - Sessions stay alive only with active use -✅ **No localhost bypass** - Even 127.0.0.1 requires pairing (can be changed if needed) -✅ **Multi-device isolation** - Each Atem has independent session -✅ **Persistent storage** - Sessions survive restarts -✅ **Auto-cleanup** - Expired sessions removed automatically - -## Performance - -- **Session validation**: O(1) hash lookup -- **Session refresh**: O(1) update + disk write (async) -- **Cleanup**: O(n) filter (runs on startup only) -- **Disk I/O**: JSON files, pretty-printed for debugging -- **Thread safety**: All SessionStore ops use concurrent queue with barriers - -## Known Issues - -1. **Atem auth not yet implemented** - Client doesn't send auth messages yet -2. **Relay server missing session support** - Only Astation local server has it -3. **No Swift tests** - SessionStore needs unit tests -4. **Pairing dialog blocks main thread** - Should use async alert on macOS 12+ -5. **No session revocation UI** - User can't manually revoke sessions (only via expiry) +The practical Atem test starts a real loopback WebSocket server, sends the v2 +LAN challenge through the production client, validates the stable Atem identity +and pairing request, and confirms that a denial propagates to the caller. -## Conclusion +The coordinated Astation tests start the real NIO WebSocket server and cover an +offline loopback client, a rejected forged proof, five concurrent Atem clients, +and direct auth through a real non-loopback LAN interface. -The foundation is solid! Session storage, expiry, refresh, and multi-device support all work on both sides. Just need to connect the dots: -- Atem client sending auth messages -- Relay server validating sessions -- Tests for confidence +## Known limitation -Security is strong with pairing required everywhere and 7-day expiry forcing periodic re-approval. +Direct LAN currently uses plaintext `ws://`. HMAC verifies possession but does +not encrypt pairing credentials or application traffic and does not prevent an +active man-in-the-middle. Do not call direct LAN production-ready until WSS with +persistent certificate pinning, or an equivalent authenticated encrypted +transport, is implemented. diff --git a/designs/universal-sessions.md b/designs/universal-sessions.md index b8c7000..f90b19e 100644 --- a/designs/universal-sessions.md +++ b/designs/universal-sessions.md @@ -1,324 +1,75 @@ -# Universal Sessions - Complete Implementation +# Portable Device Sessions -## Overview +Status: implemented by device authentication v2. -Implemented a universal session system where **one pairing per Atem+Astation pair works across all endpoints** (local WebSocket, relay server, VPN). No need to re-pair when switching between connection methods. +## Goal -## Key Innovation +Pair an Atem installation with an Astation once, then reuse that device session +over direct LAN, VPN, or the identity relay. Loopback has an additional same-user +bootstrap path so it works offline without an approval prompt. -**Sessions are keyed by `astation_id`, not endpoint URL.** +## Identity model -### Before (Endpoint-Based): -``` -Machine B connects to ws://127.0.0.1:8080/ws -→ Pair → Session saved for "127.0.0.1" - -Later: Machine B connects to relay (local failed) -→ Different endpoint → No session found → Pair again ❌ -``` - -### After (Astation-Based): -``` -Machine B connects to ws://127.0.0.1:8080/ws -→ Receives astation_id="astation-home-abc123" -→ Pair → Session saved for "astation-home-abc123" +- `instance_id` is the stable UUID for one Atem installation. +- `atem_id` is the stable, human-readable relay/device ID derived from that UUID. +- `astation_id` identifies one Astation installation on every transport. +- `session_id` selects a paired record but is not a credential. +- `token` is the secret used to prove possession with HMAC-SHA256. -Later: Machine B connects to relay (local failed) -→ Receives same astation_id="astation-home-abc123" -→ Session found → Auto-authenticated ✅ -``` - -## Architecture - -### Session Structure +Atem stores sessions keyed by `astation_id`, which allows one Atem installation +to connect to multiple Astations without overwriting credentials. Astation binds +each session to one `atem_id`, which prevents a copied session ID from being +claimed by a different device. -**Atem side (`~/.config/atem/sessions.json`):** ```json { "sessions": { - "astation-home-abc123": { - "session_id": "sess-xyz", - "token": "tok-abc", - "astation_id": "astation-home-abc123", - "hostname": "my-laptop", - "last_activity": 1707600000 - }, - "astation-office-def456": { - "session_id": "sess-789", - "token": "tok-def", - "astation_id": "astation-office-def456", - "hostname": "my-laptop", - "last_activity": 1707500000 - } - } -} -``` - -**Astation side (`~/Library/Application Support/Astation/sessions.json`):** -- Same structure as before (keyed by session_id) -- Also has identity file: `~/Library/Application Support/Astation/identity.txt` - -### Authentication Flow - -``` -1. Atem connects to WebSocket (local or relay) - ↓ -2. Astation sends: { status: "auth_required", astation_id: "astation-home-abc123" } - ↓ -3. Atem loads SessionManager, looks up "astation-home-abc123" - ├─ Found + valid → Send session_id → Auto-authenticated ✅ - └─ Not found or expired → Pair → Save under "astation-home-abc123" -``` - -## Implementation Details - -### Rust (Atem) - -#### `src/auth.rs` - -**SessionManager:** -```rust -pub struct SessionManager { - sessions: HashMap, // Key: astation_id -} - -impl SessionManager { - pub fn load() -> Result // From ~/.config/atem/sessions.json - pub fn save(&self) -> Result<()> - pub fn get(&self, astation_id: &str) -> Option<&AuthSession> - pub fn get_mut(&mut self, astation_id: &str) -> Option<&mut AuthSession> - pub fn save_session(&mut self, session: AuthSession) -> Result<()> - pub fn remove(&mut self, astation_id: &str) -> Result<()> - pub fn active_sessions(&self) -> Vec<&AuthSession> - pub fn cleanup_expired(&mut self) -> Result<()> -} -``` - -**AuthSession:** -```rust -pub struct AuthSession { - pub session_id: String, - pub token: String, - pub astation_id: String, // NEW - identifies which Astation - pub hostname: String, - pub last_activity: u64, -} -``` - -#### `src/websocket_client.rs` - -**authenticate() method:** -```rust -async fn authenticate(&mut self) -> Result<()> { - // 1. Wait for auth_required, extract astation_id - let auth_required = wait_for_auth_required().await?; - let astation_id = auth_required.data.get("astation_id")?; - - // 2. Load SessionManager (multiple Astation sessions) - let mut session_mgr = SessionManager::load().unwrap_or_default(); - - // 3. Try session auth for THIS Astation - if let Some(session) = session_mgr.get(&astation_id) { - if try_session_auth(session).await.is_ok() { - session.refresh(); - session_mgr.save()?; - return Ok(()); - } + "astation-home": { + "session_id": "...", + "token": "...", + "astation_id": "astation-home", + "hostname": "office-ubuntu", + "last_activity": 1784678400 } - - // 4. Fall back to pairing - authenticate_with_pairing(&astation_id).await?; - Ok(()) -} -``` - -### Swift (Astation) - -#### `Sources/Menubar/AstationIdentity.swift` (NEW) - -```swift -class AstationIdentity { - static let shared = AstationIdentity() - let id: String // e.g., "astation-abc123-def456-..." - - // Persists to ~/Library/Application Support/Astation/identity.txt - // Generated once on first launch, reused forever -} -``` - -#### `Sources/Menubar/AstationWebSocketServer.swift` (MODIFIED) - -```swift -// Send auth_required with astation_id -let authChallenge = AstationMessage.statusUpdate( - status: "auth_required", - data: [ - "clientId": clientId, - "astation_id": AstationIdentity.shared.id // NEW - ] -) -sendMessage(authChallenge, to: clientId) -``` - -## Multi-Astation Support - -Same Atem can connect to multiple Astation instances, each with independent sessions: - -``` -~/.config/atem/sessions.json: -{ - "sessions": { - "astation-home-123": {...}, // Home Mac Mini - "astation-office-456": {...}, // Work MacBook Pro - "astation-lab-789": {...} // Lab iMac } } ``` -Each Astation has independent: -- Session ID -- Token -- Last activity timestamp -- Expiry (7 days per Astation) - -## Connection Scenarios - -### Scenario 1: Endpoint Switching (Core Feature) -``` -Day 1: Atem connects locally (ws://127.0.0.1:8080/ws) - → Astation sends astation_id="astation-home-abc" - → Pair → Session saved under "astation-home-abc" - -Day 2: Local network down, relay kicks in (https://station.agora.build) - → Astation sends same astation_id="astation-home-abc" - → Session found → Auto-authenticated (no pairing!) -``` - -### Scenario 2: Multiple Atem Instances -``` -Laptop (Atem A): astation_id="astation-home" → sess-aaa -Desktop (Atem B): astation_id="astation-home" → sess-bbb -Phone (Atem C): astation_id="astation-home" → sess-ccc - -All three maintain independent sessions with the same Astation. -``` - -### Scenario 3: Multiple Astation Instances -``` -Atem connects to: -- Home Astation: astation_id="astation-home" → sess-111 -- Office Astation: astation_id="astation-office" → sess-222 - -Sessions don't interfere - completely independent. -``` - -## Session Expiry & Refresh - -- **Expiry**: 7 days of inactivity (per session) -- **Refresh**: On every connection and every message -- **Activity tracking**: `last_activity` timestamp updated automatically -- **Cleanup**: Expired sessions removed on load - -## Security Model - -| Aspect | Implementation | -|--------|----------------| -| **First-time pairing** | Required for ALL connections (local/LAN/VPN/relay) | -| **Session validity** | 7 days of inactivity before re-pairing required | -| **Activity refresh** | Automatic on connection and messages | -| **Per-device isolation** | Each Atem has independent session | -| **Per-Astation isolation** | Each Astation has independent session | -| **Endpoint portability** | Session works on local AND relay | -| **Token security** | Astation-generated, stored locally | - -## Testing - -### Rust Tests (28 tests, all passing) - -**SessionManager tests:** -- `session_manager_starts_empty` -- `session_manager_save_and_load` -- `session_manager_get_valid_session` -- `session_manager_get_expired_session_returns_none` -- `session_manager_get_nonexistent` -- `session_manager_multiple_astations` -- `session_manager_cleanup_expired` -- `session_manager_same_atem_different_endpoints` ← **KEY TEST** -- `session_manager_get_mut_allows_refresh` +The file is `~/.config/atem/sessions.json`, created as `0600` under a `0700` +directory. -**AuthSession tests (updated):** -- All existing tests updated to include `astation_id` parameter -- All 8 session expiry/refresh tests still pass +## Transport portability -### Manual Testing Checklist +Astation includes the same `astation_id` in direct and relay challenges. Atem +therefore finds the same record regardless of the endpoint and calculates a new +proof using the challenge for that connection. The token never needs to be sent +again after pairing. -- [ ] Fresh install: pair with local Astation -- [ ] Disconnect and reconnect locally (no re-pairing) -- [ ] Switch to relay server (local fails) → auto-authenticated -- [ ] Wait 8 days → session expired → re-pairing required -- [ ] Connect to different Astation → separate pairing -- [ ] Multiple Atem instances → independent sessions +This supports both directions: -## Files Modified +- direct LAN becomes unavailable, so Atem reconnects through the relay; +- relay or internet becomes unavailable, so Atem connects to a configured LAN + address using the existing session. -### Atem (Rust) -- ✅ `src/auth.rs` - Added SessionManager, updated AuthSession -- ✅ `src/websocket_client.rs` - Extract astation_id, use SessionManager -- ✅ `src/cli.rs` - Updated login command -- ✅ `designs/universal-sessions.md` - This file +## Lifecycle -### Astation (Swift) -- ✅ `Sources/Menubar/AstationIdentity.swift` - NEW FILE -- ✅ `Sources/Menubar/AstationWebSocketServer.swift` - Send astation_id - -### Documentation -- ✅ `designs/session-auth.md` - Original session design -- ✅ `designs/universal-sessions.md` - Universal session architecture - -## Migration from Old Sessions - -Old single-session file (`~/.config/atem/session.json`) will be ignored. Users will need to re-pair once after upgrade. The old file can be safely deleted. - -**Migration steps:** -1. Atem loads SessionManager (empty on first run) -2. Connects to Astation -3. Receives astation_id -4. No session found → pairing required -5. Session saved under astation_id -6. Future connections auto-authenticated - -## Benefits - -✅ **User convenience**: Pair once, works everywhere -✅ **Endpoint resilience**: Local → relay fallback seamless -✅ **Multi-device**: Multiple Atem instances supported -✅ **Multi-Astation**: Multiple Astation instances supported -✅ **Security**: Still requires explicit pairing approval -✅ **Expiry**: 7-day inactivity forces periodic re-approval - -## Relay Server TODO - -The relay server (`relay-server/`) needs session verification support: - -```rust -// When Atem connects via relay: -// 1. Atem sends session_id to relay -// 2. Relay asks Astation: "Is session sess-xyz valid?" -// 3. Astation checks SessionStore, responds yes/no -// 4. Relay allows/denies connection -``` +- Sessions expire after seven days of inactivity. +- A successful proof refreshes activity on both peers. +- An invalid or expired session falls back to explicit pairing. +- Existing legacy session records can be read, then bind to the first `atem_id` + that successfully proves token possession. +- The older `~/.config/atem/session.json` path is legacy and is not the v2 + multi-Astation source of truth. -This is a separate task and not blocking the current implementation. For now, relay connections will require fresh pairing. +## Multiple clients -## Conclusion +One Astation can keep several local, LAN, and relay Atem connections active at +the same time. Each Atem installation has its own session and stable device ID. +Multiple processes that share one Atem config directory also share an identity; +use separate config homes when process-level identities are required. -The universal session system is **complete and tested** on the Atem side. Users can now: -- Pair once with each Astation -- Switch freely between local and relay connections -- Maintain multiple Astation sessions simultaneously -- Enjoy 7 days of auto-authenticated convenience +## Security constraints -Next steps: -1. Test with real Astation instance -2. Implement relay server session verification -3. Consider session revocation UI +Endpoint portability does not make every transport equally secure. The public +relay uses WSS, while the current direct LAN endpoint is plaintext WebSocket. +See `session-auth.md` for the protocol boundary and required WSS pinning work. diff --git a/src/app.rs b/src/app.rs index f99a4ad..5e4f759 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1640,7 +1640,10 @@ impl App { if self.astation_connected || self.astation_connect_rx.is_some() { return; } - let config = self.config.clone(); + // Pairing can learn the relay identity while the TUI is already running. + // Refresh before every attempt so the next reconnect can use it immediately. + let config = AtemConfig::load().unwrap_or_else(|_| self.config.clone()); + self.config = config.clone(); let (tx, rx) = tokio::sync::oneshot::channel(); self.astation_connect_rx = Some(rx); tokio::spawn(async move { @@ -1658,7 +1661,10 @@ impl App { // If we have a valid session, try local with session auth if let Some(ref sess) = session { if sess.is_valid() { - if let Ok(()) = client.connect_with_session(local_url, &sess.session_id).await { + if let Ok(()) = client + .connect_with_session(local_url, &sess.session_id) + .await + { let _ = tx.send(Ok((client, None))); return; } @@ -1668,7 +1674,7 @@ impl App { // No valid session - try local with fresh auth (5s timeout) let mut client = crate::websocket_client::AstationClient::new(); let local_url = config.astation_ws().to_string(); - match client.connect(&local_url).await { + match client.connect_without_pairing(&local_url).await { Ok(()) => { let _ = tx.send(Ok((client, Some("local".to_string())))); return; @@ -1680,7 +1686,10 @@ impl App { if let Some(relay_code) = config.astation_relay_code.clone() { let relay_url = config.astation_relay_url().to_string(); let mut relay_client = crate::websocket_client::AstationClient::new(); - match relay_client.connect_relay_identity(&relay_url, &relay_code).await { + match relay_client + .connect_relay_identity_without_pairing(&relay_url, &relay_code) + .await + { Ok(()) => { let _ = tx.send(Ok((relay_client, Some(relay_code)))); return; diff --git a/src/auth.rs b/src/auth.rs index 699018a..63e87a0 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,6 +1,7 @@ use anyhow::{Result, anyhow}; use rand::Rng; use serde::{Deserialize, Serialize}; +use std::fs; use std::time::Duration; const DEFAULT_SERVER_URL: &str = "https://station.agora.build"; @@ -68,15 +69,15 @@ impl SessionManager { /// Returns empty SessionManager if file doesn't exist. pub fn load() -> Result { let path = Self::sessions_path()?; + Self::load_from(&path) + } - if !path.exists() { + pub(crate) fn load_from(path: &std::path::Path) -> Result { + let Some(content) = read_private_file(path)? else { return Ok(Self::default()); - } - - let content = std::fs::read_to_string(&path) - .map_err(|e| anyhow!("Failed to read sessions file: {}", e))?; + }; - let manager: SessionManager = serde_json::from_str(&content) + let manager: SessionManager = serde_json::from_slice(&content) .map_err(|e| anyhow!("Failed to parse sessions file: {}", e))?; Ok(manager) @@ -85,18 +86,21 @@ impl SessionManager { /// Save all sessions to disk. pub fn save(&self) -> Result<()> { let path = Self::sessions_path()?; + self.save_to(&path) + } + pub(crate) fn save_to(&self, path: &std::path::Path) -> Result<()> { // Ensure parent directory exists if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) + fs::create_dir_all(parent) .map_err(|e| anyhow!("Failed to create config directory: {}", e))?; + set_private_directory_permissions(parent)?; } let json = serde_json::to_string_pretty(self) .map_err(|e| anyhow!("Failed to serialize sessions: {}", e))?; - std::fs::write(&path, json) - .map_err(|e| anyhow!("Failed to write sessions file: {}", e))?; + write_private_file(path, json.as_bytes())?; Ok(()) } @@ -113,9 +117,13 @@ impl SessionManager { /// Save or update a session for a specific Astation. pub fn save_session(&mut self, session: AuthSession) -> Result<()> { + self.insert_session(session); + self.save() + } + + pub(crate) fn insert_session(&mut self, session: AuthSession) { let astation_id = session.astation_id.clone(); self.sessions.insert(astation_id, session); - self.save() } /// Remove a session for a specific Astation. @@ -143,6 +151,104 @@ impl SessionManager { } } +#[cfg(unix)] +pub(crate) fn set_private_directory_permissions(path: &std::path::Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|e| anyhow!("Failed to secure config directory: {}", e)) +} + +#[cfg(not(unix))] +pub(crate) fn set_private_directory_permissions(_path: &std::path::Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn validate_private_file(file: &fs::File) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + let metadata = file + .metadata() + .map_err(|e| anyhow!("Failed to inspect private file: {}", e))?; + let current_uid = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != current_uid { + return Err(anyhow!("Private file must be a regular file owned by the current user")); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_private_file(file: &fs::File) -> Result<()> { + let metadata = file + .metadata() + .map_err(|e| anyhow!("Failed to inspect private file: {}", e))?; + if !metadata.is_file() { + return Err(anyhow!("Private file must be a regular file")); + } + Ok(()) +} + +#[cfg(unix)] +fn set_private_file_permissions(file: &fs::File) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|e| anyhow!("Failed to secure sessions file: {}", e)) +} + +#[cfg(not(unix))] +fn set_private_file_permissions(_file: &fs::File) -> Result<()> { + Ok(()) +} + +pub(crate) fn read_private_file(path: &std::path::Path) -> Result>> { + use std::io::Read; + + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = match options.open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(anyhow!("Failed to open private file: {}", error)), + }; + validate_private_file(&file)?; + // Repair pre-v2 files before bearer tokens are read into memory. + set_private_file_permissions(&file)?; + + let mut contents = Vec::new(); + file.read_to_end(&mut contents) + .map_err(|e| anyhow!("Failed to read private file: {}", e))?; + Ok(Some(contents)) +} + +pub(crate) fn write_private_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { + use std::io::Write; + + let mut options = fs::OpenOptions::new(); + options.write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(path) + .map_err(|e| anyhow!("Failed to open private file: {}", e))?; + validate_private_file(&file)?; + set_private_file_permissions(&file)?; + file.set_len(0) + .map_err(|e| anyhow!("Failed to truncate private file: {}", e))?; + file.write_all(contents) + .map_err(|e| anyhow!("Failed to write private file: {}", e))?; + file.sync_all() + .map_err(|e| anyhow!("Failed to sync private file: {}", e)) +} + /// Generate a random 8-digit OTP code. pub fn generate_otp() -> String { let mut rng = rand::thread_rng(); @@ -336,6 +442,54 @@ pub async fn run_login_flow(server_url: Option<&str>, astation_id: &str) -> Resu mod tests { use super::*; + #[cfg(unix)] + #[test] + fn private_file_write_uses_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("sessions.json"); + write_private_file(&path, br#"{"token":"secret"}"#).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + assert_eq!(fs::read(&path).unwrap(), br#"{"token":"secret"}"#); + } + + #[cfg(unix)] + #[test] + fn private_file_read_repairs_existing_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("sessions.json"); + fs::write(&path, br#"{"token":"secret"}"#).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + assert_eq!( + read_private_file(&path).unwrap().unwrap(), + br#"{"token":"secret"}"# + ); + let metadata = fs::metadata(&path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + + #[cfg(unix)] + #[test] + fn private_file_io_refuses_symlinks_without_touching_target() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.json"); + let link = directory.path().join("sessions.json"); + fs::write(&target, b"target contents").unwrap(); + symlink(&target, &link).unwrap(); + + assert!(read_private_file(&link).is_err()); + assert!(write_private_file(&link, b"replacement").is_err()); + assert_eq!(fs::read(&target).unwrap(), b"target contents"); + } + #[test] fn otp_is_8_digits() { let otp = generate_otp(); diff --git a/src/cli.rs b/src/cli.rs index 4cd171c..c346b86 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1224,18 +1224,20 @@ async fn run_pair(save: bool) -> Result<()> { } // Resolve save preference: --save skips the prompt; otherwise ask interactively. - let save_credentials = if save { + let requested_save_credentials = if save { true } else { prompt_save_credentials() }; - // Send save preference. Astation uses this to decide what to put in - // the subsequent CredentialSync message. + // Send the preference for peers that support it. Atem still treats the + // local choice as authoritative when it stores the received credentials. client - .send_message(crate::websocket_client::AstationMessage::PairSavePreference { - save_credentials, - }) + .send_message( + crate::websocket_client::AstationMessage::PairSavePreference { + save_credentials: requested_save_credentials, + }, + ) .await?; println!("Waiting for Astation to send SSO credentials..."); @@ -1250,7 +1252,7 @@ async fn run_pair(save: bool) -> Result<()> { expires_at, login_id, astation_id, - save_credentials, + save_credentials: server_save_credentials, }) => { return Ok::<_, anyhow::Error>(( access_token, @@ -1258,7 +1260,7 @@ async fn run_pair(save: bool) -> Result<()> { expires_at, login_id, astation_id, - save_credentials, + server_save_credentials, )); } Some(_) => continue, @@ -1269,7 +1271,29 @@ async fn run_pair(save: bool) -> Result<()> { .await; match received { - Ok(Ok((access_token, refresh_token, expires_at, login_id, astation_id, save_credentials))) => { + Ok(Ok(( + access_token, + refresh_token, + expires_at, + login_id, + astation_id, + _server_save_credentials, + ))) => { + if result != "local" { + println!("Establishing an authenticated relay session..."); + drop(client); + let mut identity_client = crate::websocket_client::AstationClient::new(); + identity_client + .connect_relay_identity(config.astation_relay_url(), &astation_id) + .await + .map_err(|error| { + anyhow::anyhow!( + "Relay room paired, but device authentication failed: {}", + error + ) + })?; + } + crate::config::AtemConfig::store_astation_relay_code(&astation_id); let mut store = crate::credentials::CredentialStore::load(); let now = crate::credentials::CredentialEntry::now_secs(); store.upsert(crate::credentials::CredentialEntry::new_paired( @@ -1278,7 +1302,7 @@ async fn run_pair(save: bool) -> Result<()> { expires_at, login_id.clone(), astation_id, - save_credentials, + requested_save_credentials, now, )); store.save()?; @@ -1286,7 +1310,7 @@ async fn run_pair(save: bool) -> Result<()> { Some(id) => println!("Paired with Astation. (SSO: {})", id), None => println!("Paired with Astation."), } - if save_credentials { + if requested_save_credentials { println!("Credentials saved — will work offline."); } else { println!("Credentials are session-only (5 min grace period after disconnect)."); diff --git a/src/config.rs b/src/config.rs index 4b7b901..1a6aa6b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,8 @@ pub struct AtemConfig { pub rtm_account: Option, pub astation_ws: Option, pub astation_relay_url: Option, + /// Most recently authenticated Astation identity. Learned automatically and + /// used to select its persistent relay room on remote reconnect. pub astation_relay_code: Option, pub diagram_server_url: Option, pub bff_url: Option, @@ -159,6 +161,11 @@ impl AtemConfig { Self::write_config_string("atem_id", id); } + /// Remember the authenticated Astation as the default identity-relay target. + pub fn store_astation_relay_code(astation_id: &str) { + Self::write_config_string("astation_relay_code", astation_id); + } + /// Persist the config to disk. /// /// Non-sensitive settings → `~/.config/atem/config.toml` (plaintext) @@ -269,6 +276,10 @@ impl AtemConfig { "astation_relay_url: {}", self.astation_relay_url.as_deref().unwrap_or("(not set)") )); + lines.push(format!( + "astation_relay_code: {}", + self.astation_relay_code.as_deref().unwrap_or("(not learned)") + )); lines.push(format!( "diagram_server_url: {}", self.diagram_server_url.as_deref().unwrap_or("(not set)") @@ -653,11 +664,8 @@ impl crate::auth::AuthSession { /// Load saved session from disk. Returns None if not found. pub fn load_saved() -> Option { let path = Self::session_path(); - if !path.exists() { - return None; - } - let content = fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() + let content = crate::auth::read_private_file(&path).ok()??; + serde_json::from_slice(&content).ok() } /// Save session to disk. @@ -665,8 +673,9 @@ impl crate::auth::AuthSession { let path = Self::session_path(); let dir = path.parent().unwrap(); fs::create_dir_all(dir)?; + crate::auth::set_private_directory_permissions(dir)?; let json = serde_json::to_string_pretty(self)?; - fs::write(&path, json)?; + crate::auth::write_private_file(&path, json.as_bytes())?; Ok(()) } diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 8840801..7525329 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -1,6 +1,9 @@ use anyhow::{Result, anyhow}; use futures_util::{SinkExt, StreamExt}; +use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::mpsc; use tokio::time::Duration; @@ -283,6 +286,11 @@ pub struct AtemInstance { pub struct AstationClient { sender: Option>, receiver: Option>, + transport_tasks: Vec>, + atem_id_override: Option, + session_path_override: Option, + #[cfg(test)] + relay_code_path_override: Option, /// Set to true when the WebSocket reader task exits (connection dropped). ws_closed: bool, } @@ -292,29 +300,107 @@ impl AstationClient { Self { sender: None, receiver: None, + transport_tasks: Vec::new(), + atem_id_override: None, + session_path_override: None, + #[cfg(test)] + relay_code_path_override: None, ws_closed: false, } } + #[cfg(test)] + fn new_with_test_state(atem_id: &str, session_path: std::path::PathBuf) -> Self { + let mut client = Self::new(); + client.atem_id_override = Some(atem_id.to_string()); + client.relay_code_path_override = Some(session_path.with_file_name("relay-code")); + client.session_path_override = Some(session_path); + client + } + + fn resolved_atem_id(&self, hostname: &str) -> String { + self.atem_id_override + .clone() + .unwrap_or_else(|| resolved_atem_id(hostname)) + } + + fn load_session_manager(&self) -> Result { + match self.session_path_override.as_deref() { + Some(path) => crate::auth::SessionManager::load_from(path), + None => crate::auth::SessionManager::load(), + } + } + + fn save_session_manager(&self, manager: &crate::auth::SessionManager) -> Result<()> { + match self.session_path_override.as_deref() { + Some(path) => manager.save_to(path), + None => manager.save(), + } + } + + fn persist_session(&self, session: crate::auth::AuthSession) -> Result<()> { + let mut manager = self.load_session_manager()?; + manager.insert_session(session); + self.save_session_manager(&manager) + } + + fn remember_astation_relay_code(&self, astation_id: &str) { + #[cfg(test)] + if let Some(path) = self.relay_code_path_override.as_deref() { + let _ = fs::write(path, astation_id); + return; + } + + AtemConfig::store_astation_relay_code(astation_id); + } + + fn abort_transport(&mut self) { + self.sender = None; + self.receiver = None; + for task in self.transport_tasks.drain(..) { + task.abort(); + } + self.ws_closed = true; + } + /// Connect to Astation using a saved auth session. /// Connect with session (now handled via message-based auth) /// This is now just an alias for connect() since auth happens after connection. pub async fn connect_with_session(&mut self, base_url: &str, _session_id: &str) -> Result<()> { // Session auth now happens inside connect() via authenticate() // The session_id parameter is ignored - session is loaded from disk - self.connect(base_url).await + self.connect_without_pairing(base_url).await } /// Connect WebSocket and authenticate (local Astation connections). pub async fn connect(&mut self, url: &str) -> Result<()> { + self.connect_with_auth_mode(url, true).await + } + + /// Connect using an existing proof or same-user bootstrap without prompting. + pub async fn connect_without_pairing(&mut self, url: &str) -> Result<()> { + self.connect_with_auth_mode(url, false).await + } + + async fn connect_with_auth_mode(&mut self, url: &str, allow_pairing: bool) -> Result<()> { self.connect_raw(url).await?; - self.authenticate(Duration::from_secs(5)).await?; - Ok(()) + let result = self + .authenticate( + Duration::from_secs(5), + Duration::from_secs(300), + allow_pairing, + ) + .await; + if result.is_err() { + self.abort_transport(); + } + result } /// Connect WebSocket transport only, without authentication. /// Used by relay flow where auth happens separately after code exchange. pub async fn connect_raw(&mut self, url: &str) -> Result<()> { + self.abort_transport(); let (ws_stream, _) = tokio::time::timeout(Duration::from_secs(5), connect_async(url)) .await .map_err(|_| anyhow!("WebSocket connection timed out after 5s"))? @@ -325,7 +411,7 @@ impl AstationClient { let (msg_tx, mut msg_rx) = mpsc::unbounded_channel::(); // Spawn task to handle outgoing messages - tokio::spawn(async move { + let writer_task = tokio::spawn(async move { while let Some(message) = msg_rx.recv().await { if let Ok(json) = serde_json::to_string(&message) { if let Err(_) = write.send(Message::Text(json)).await { @@ -336,7 +422,7 @@ impl AstationClient { }); // Spawn task to handle incoming messages - tokio::spawn(async move { + let reader_task = tokio::spawn(async move { while let Some(message) = read.next().await { match message { Ok(Message::Text(text)) => { @@ -359,6 +445,7 @@ impl AstationClient { self.sender = Some(msg_tx); self.receiver = Some(rx); + self.transport_tasks = vec![writer_task, reader_task]; self.ws_closed = false; Ok(()) @@ -366,12 +453,18 @@ impl AstationClient { /// Authenticate with Astation after WebSocket connection. /// Waits for auth_required, then sends session ID or pairing code. - /// `auth_timeout` controls how long to wait for Astation to send auth_required - /// (5s for local, 5 minutes for relay where user enters code manually). - async fn authenticate(&mut self, auth_timeout: Duration) -> Result<()> { + /// `challenge_timeout` controls how long to wait for `auth_required`. + /// `completion_timeout` bounds everything after the challenge, including + /// session proof and interactive pairing. + async fn authenticate( + &mut self, + challenge_timeout: Duration, + completion_timeout: Duration, + allow_pairing: bool, + ) -> Result<()> { // Wait for auth_required message (with timeout) let auth_required = tokio::time::timeout( - auth_timeout, + challenge_timeout, self.wait_for_message(|msg| { matches!(msg, AstationMessage::StatusUpdate { status, .. } if status == "auth_required") }) @@ -380,24 +473,103 @@ impl AstationClient { .map_err(|_| anyhow!("Timeout waiting for auth_required"))? .ok_or_else(|| anyhow!("Connection closed before auth_required"))?; - // Extract astation_id from auth_required message - let astation_id = if let AstationMessage::StatusUpdate { data, .. } = &auth_required { - data.get("astation_id") - .ok_or_else(|| anyhow!("auth_required missing astation_id"))? - .clone() - } else { - return Err(anyhow!("Invalid auth_required message")); - }; + tokio::time::timeout( + completion_timeout, + self.authenticate_after_challenge(auth_required, allow_pairing), + ) + .await + .map_err(|_| anyhow!("Authentication timed out"))? + } + + async fn authenticate_after_challenge( + &mut self, + auth_required: AstationMessage, + allow_pairing: bool, + ) -> Result<()> { + // Extract the server identity and challenge from auth_required. + let (astation_id, challenge, transport, protocol) = + if let AstationMessage::StatusUpdate { data, .. } = &auth_required { + let astation_id = data + .get("astation_id") + .ok_or_else(|| anyhow!("auth_required missing astation_id"))? + .clone(); + ( + astation_id, + data.get("challenge").cloned(), + data.get("transport").cloned(), + data.get("protocol").cloned(), + ) + } else { + return Err(anyhow!("Invalid auth_required message")); + }; + + if protocol.as_deref() != Some("2") { + return Err(anyhow!("Astation authentication protocol v2 is required")); + } + let challenge = challenge + .as_deref() + .filter(|value| { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + .ok_or_else(|| anyhow!("Invalid Astation authentication challenge"))?; + let transport = transport + .as_deref() + .filter(|value| matches!(*value, "loopback" | "lan" | "relay")) + .ok_or_else(|| anyhow!("Invalid Astation authentication transport"))?; + + let hostname = crate::auth::get_hostname(); + let atem_id = self.resolved_atem_id(&hostname); + + if transport == "loopback" { + let token = read_local_bootstrap_token() + .ok_or_else(|| anyhow!("Astation local bootstrap token is unavailable"))?; + let mut auth_data = std::collections::HashMap::new(); + auth_data.insert("method".to_string(), "local_proof".to_string()); + auth_data.insert("atem_id".to_string(), atem_id.clone()); + auth_data.insert("hostname".to_string(), hostname.clone()); + auth_data.insert( + "proof".to_string(), + device_auth_proof(&token, challenge, &astation_id, &atem_id, "local")?, + ); + self.send_message(AstationMessage::StatusUpdate { + status: "auth".to_string(), + data: auth_data, + }) + .await?; + + return match self.wait_for_auth_response(&astation_id).await? { + Some(AuthResponse::Authenticated) => Ok(()), + Some(AuthResponse::Denied(message)) => { + Err(anyhow!("Local authentication denied: {}", message)) + } + Some(AuthResponse::SessionExpired) => { + Err(anyhow!("Unexpected local session expiry")) + } + None => Err(anyhow!("Connection closed during local authentication")), + }; + } // Load session manager - let mut session_mgr = crate::auth::SessionManager::load() - .unwrap_or_default(); + let mut session_mgr = self.load_session_manager()?; // Try session-based auth first if we have a saved session for this Astation if let Some(session) = session_mgr.get(&astation_id) { - // Send session auth let mut auth_data = std::collections::HashMap::new(); auth_data.insert("session_id".to_string(), session.session_id.clone()); + auth_data.insert("atem_id".to_string(), atem_id.clone()); + auth_data.insert( + "proof".to_string(), + device_auth_proof( + &session.token, + challenge, + &astation_id, + &atem_id, + &session.session_id, + )?, + ); let auth_msg = AstationMessage::StatusUpdate { status: "auth".to_string(), @@ -412,7 +584,11 @@ impl AstationClient { // Session auth successful - refresh and save if let Some(session) = session_mgr.get_mut(&astation_id) { session.refresh(); - let _ = session_mgr.save(); + if let Err(error) = self.save_session_manager(&session_mgr) { + eprintln!( + "Warning: could not refresh saved Astation session: {error}" + ); + } } return Ok(()); } @@ -426,12 +602,16 @@ impl AstationClient { } } - // Session auth failed or no session - use pairing - self.authenticate_with_pairing(&astation_id).await + if !allow_pairing { + return Err(anyhow!("Pairing required; run 'atem pair'")); + } + + // Session auth failed or no session - use explicit pairing. + self.authenticate_with_pairing(&astation_id, &atem_id).await } /// Authenticate using pairing code (fallback when session invalid/missing) - async fn authenticate_with_pairing(&mut self, astation_id: &str) -> Result<()> { + async fn authenticate_with_pairing(&mut self, astation_id: &str, atem_id: &str) -> Result<()> { // Generate pairing code let pairing_code = crate::auth::generate_otp(); let hostname = crate::auth::get_hostname(); @@ -444,6 +624,7 @@ impl AstationClient { let mut auth_data = std::collections::HashMap::new(); auth_data.insert("pairing_code".to_string(), pairing_code.clone()); auth_data.insert("hostname".to_string(), hostname.clone()); + auth_data.insert("atem_id".to_string(), atem_id.to_string()); let auth_msg = AstationMessage::StatusUpdate { status: "auth".to_string(), @@ -486,23 +667,22 @@ impl AstationClient { if let Some(auth_status) = data.get("status") { match auth_status.as_str() { "granted" => { - // Save new session if provided - if let (Some(session_id), Some(token)) = - (data.get("session_id"), data.get("token")) - { - let hostname = crate::auth::get_hostname(); - let session = crate::auth::AuthSession::new( - session_id.clone(), - token.clone(), - astation_id.to_string(), - hostname, - ); - - // Save to session manager - let mut session_mgr = crate::auth::SessionManager::load() - .unwrap_or_default(); - let _ = session_mgr.save_session(session); - } + let session_id = data.get("session_id").ok_or_else(|| { + anyhow!("Pairing response missing session ID") + })?; + let token = data.get("token").ok_or_else(|| { + anyhow!("Pairing response missing session token") + })?; + let hostname = crate::auth::get_hostname(); + let session = crate::auth::AuthSession::new( + session_id.clone(), + token.clone(), + astation_id.to_string(), + hostname, + ); + + self.persist_session(session)?; + self.remember_astation_relay_code(astation_id); return Ok(Some(AuthResponse::Authenticated)); } "denied" => { @@ -517,11 +697,13 @@ impl AstationClient { } AstationMessage::StatusUpdate { status, data: _ } if status == "authenticated" => { // Alternative authenticated message format + self.remember_astation_relay_code(astation_id); return Ok(Some(AuthResponse::Authenticated)); } AstationMessage::StatusUpdate { status, data } if status == "error" => { if let Some(msg) = data.get("message") { - if msg.contains("expired") || msg.contains("Session expired") { + let lower = msg.to_ascii_lowercase(); + if lower.contains("expired") || lower.contains("pairing required") { return Ok(Some(AuthResponse::SessionExpired)); } return Ok(Some(AuthResponse::Denied(msg.clone()))); @@ -831,8 +1013,32 @@ impl AstationClient { /// After `atem pair`, Atem stores the identity as `astation_relay_code` in config. /// The TUI calls this to auto-connect without a new `atem pair`. /// - /// Flow: connect_raw → send "hello" → Astation calls addClient → sends credentialSync. - pub async fn connect_relay_identity(&mut self, relay_url: &str, identity_code: &str) -> Result<()> { + /// Flow: connect_raw → hello → challenge/response authentication. + pub async fn connect_relay_identity( + &mut self, + relay_url: &str, + identity_code: &str, + ) -> Result<()> { + self.connect_relay_identity_with_mode(relay_url, identity_code, true) + .await + } + + /// Reconnect to an identity room using existing credentials only. + pub async fn connect_relay_identity_without_pairing( + &mut self, + relay_url: &str, + identity_code: &str, + ) -> Result<()> { + self.connect_relay_identity_with_mode(relay_url, identity_code, false) + .await + } + + async fn connect_relay_identity_with_mode( + &mut self, + relay_url: &str, + identity_code: &str, + allow_pairing: bool, + ) -> Result<()> { let ws_scheme = if relay_url.starts_with("https://") { relay_url.replace("https://", "wss://") } else { @@ -846,12 +1052,7 @@ impl AstationClient { let hostname = hostname::get() .map(|h| h.to_string_lossy().to_string()) .unwrap_or_else(|_| "unknown".to_string()); - let atem_id = crate::config::AtemConfig::stored_atem_id().unwrap_or_else(|| { - let instance_id = crate::config::AtemConfig::ensure_instance_id(); - let id = build_atem_id(&hostname, &instance_id); - crate::config::AtemConfig::store_atem_id(&id); - id - }); + let atem_id = self.resolved_atem_id(&hostname); let ws_url = relay_ws_url(&ws_scheme, identity_code, &atem_id); self.connect_raw(&ws_url).await?; @@ -860,12 +1061,25 @@ impl AstationClient { let mut hello_data = std::collections::HashMap::new(); hello_data.insert("hostname".to_string(), hostname.clone()); - self.send_message(AstationMessage::StatusUpdate { - status: "hello".to_string(), - data: hello_data, - }).await?; + let result = async { + self.send_message(AstationMessage::StatusUpdate { + status: "hello".to_string(), + data: hello_data, + }) + .await?; - Ok(()) + self.authenticate( + Duration::from_secs(10), + Duration::from_secs(300), + allow_pairing, + ) + .await + } + .await; + if result.is_err() { + self.abort_transport(); + } + result } /// Register with the relay service and get a pairing code. @@ -902,7 +1116,81 @@ impl AstationClient { .map(|s| s.to_string()) .ok_or_else(|| anyhow!("Relay response missing 'code' field")) } +} + +impl Drop for AstationClient { + fn drop(&mut self) { + self.abort_transport(); + } +} + +type HmacSha256 = Hmac; + +fn resolved_atem_id(hostname: &str) -> String { + if let Some(existing) = AtemConfig::stored_atem_id() { + return existing; + } + let atem_id = build_atem_id(hostname, &AtemConfig::ensure_instance_id()); + AtemConfig::store_atem_id(&atem_id); + atem_id +} + +fn device_auth_proof( + token: &str, + challenge: &str, + astation_id: &str, + atem_id: &str, + session_id: &str, +) -> Result { + let canonical = format!( + "astation-auth-v2\n{}\n{}\n{}\n{}", + challenge, astation_id, atem_id, session_id + ); + let mut mac = ::new_from_slice(token.as_bytes()) + .map_err(|_| anyhow!("Invalid device authentication key"))?; + mac.update(canonical.as_bytes()); + Ok(mac + .finalize() + .into_bytes() + .iter() + .map(|byte| format!("{:02x}", byte)) + .collect()) +} + +fn read_local_bootstrap_token() -> Option { + #[cfg(target_os = "macos")] + let path = dirs::data_dir()? + .join("Astation") + .join("local-bootstrap-token"); + #[cfg(not(target_os = "macos"))] + return None; + + #[cfg(target_os = "macos")] + read_local_bootstrap_token_from(&path) +} + +#[cfg(unix)] +fn read_local_bootstrap_token_from(path: &std::path::Path) -> Option { + use std::io::Read; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .ok()?; + let metadata = file.metadata().ok()?; + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.permissions().mode() & 0o077 != 0 + { + return None; + } + let mut token = String::new(); + file.read_to_string(&mut token).ok()?; + let token = token.trim().to_string(); + (!token.is_empty()).then_some(token) } /// Length of the host segment of an `atem_id` (truncated or padded to this). @@ -984,6 +1272,477 @@ fn relay_ws_url(relay_base: &str, identity_code: &str, atem_id: &str) -> String mod tests { use super::*; + fn isolated_client(atem_id: &str) -> (tempfile::TempDir, AstationClient) { + let directory = tempfile::tempdir().unwrap(); + let session_path = directory.path().join("sessions.json"); + let client = AstationClient::new_with_test_state(atem_id, session_path); + (directory, client) + } + + #[test] + fn device_auth_proof_matches_protocol_vector() { + let proof = device_auth_proof( + "token-abc", + "challenge-123", + "astation-home", + "atem-office", + "session-456", + ) + .unwrap(); + + assert_eq!( + proof, + "9fde5ba861c1a159d377b89e6fb3f92d245795998af958f5db3ad343d589d0ba" + ); + } + + #[cfg(unix)] + #[test] + fn local_bootstrap_token_requires_private_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("local-bootstrap-token"); + fs::write(&path, "bootstrap-secret\n").unwrap(); + + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!( + read_local_bootstrap_token_from(&path).as_deref(), + Some("bootstrap-secret") + ); + + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(read_local_bootstrap_token_from(&path), None); + } + + #[cfg(unix)] + #[test] + fn local_bootstrap_token_refuses_symlink() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("bootstrap-target"); + let link = directory.path().join("local-bootstrap-token"); + fs::write(&target, "bootstrap-secret\n").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&target, &link).unwrap(); + + assert_eq!(read_local_bootstrap_token_from(&link), None); + assert_eq!(fs::read_to_string(&target).unwrap(), "bootstrap-secret\n"); + } + + #[tokio::test] + async fn practical_websocket_v2_pairing_sends_identity_and_handles_denial() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test Astation"); + let address = listener.local_addr().unwrap(); + let astation_id = format!("astation-integration-{}", uuid::Uuid::new_v4()); + let expected_astation_id = astation_id.clone(); + + let server = tokio::spawn(async move { + let (stream, _) = listener + .accept() + .await + .expect("test Astation did not accept"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("WebSocket handshake failed"); + let auth_required = AstationMessage::StatusUpdate { + status: "auth_required".to_string(), + data: std::collections::HashMap::from([ + ("astation_id".to_string(), expected_astation_id), + ("challenge".to_string(), "a".repeat(64)), + ("transport".to_string(), "lan".to_string()), + ("protocol".to_string(), "2".to_string()), + ]), + }; + socket + .send(Message::Text( + serde_json::to_string(&auth_required).unwrap(), + )) + .await + .unwrap(); + + let frame = tokio::time::timeout(Duration::from_secs(2), socket.next()) + .await + .expect("timed out waiting for pairing request") + .expect("Atem closed before pairing request") + .expect("failed to read pairing request"); + let Message::Text(text) = frame else { + panic!("expected text pairing request"); + }; + let request: AstationMessage = serde_json::from_str(&text).unwrap(); + let AstationMessage::StatusUpdate { status, data } = request else { + panic!("expected authentication status update"); + }; + assert_eq!(status, "auth"); + assert!(!data["atem_id"].is_empty()); + assert!(!data["hostname"].is_empty()); + assert_eq!(data["pairing_code"].len(), 8); + assert!( + data["pairing_code"] + .chars() + .all(|value| value.is_ascii_digit()) + ); + assert!(!data.contains_key("session_id")); + assert!(!data.contains_key("proof")); + + let denied = AstationMessage::StatusUpdate { + status: "auth".to_string(), + data: std::collections::HashMap::from([ + ("status".to_string(), "denied".to_string()), + ("message".to_string(), "integration denial".to_string()), + ]), + }; + socket + .send(Message::Text(serde_json::to_string(&denied).unwrap())) + .await + .unwrap(); + }); + + let (_state, mut client) = isolated_client("atem-test-denial"); + let error = client + .connect(&format!("ws://{address}")) + .await + .expect_err("denied pairing unexpectedly authenticated"); + assert!( + error + .to_string() + .contains("Pairing denied: integration denial") + ); + server.await.unwrap(); + } + + #[cfg(unix)] + #[tokio::test] + async fn practical_websocket_pairing_fails_when_session_cannot_be_persisted() { + use std::os::unix::fs::symlink; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test Astation"); + let address = listener.local_addr().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let session_path = directory.path().join("sessions.json"); + let target_path = directory.path().join("protected-target"); + let server_session_path = session_path.clone(); + let server_target_path = target_path.clone(); + + let server = tokio::spawn(async move { + let (stream, _) = listener + .accept() + .await + .expect("test Astation did not accept"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("WebSocket handshake failed"); + let auth_required = AstationMessage::StatusUpdate { + status: "auth_required".to_string(), + data: std::collections::HashMap::from([ + ( + "astation_id".to_string(), + "astation-persist-failure".to_string(), + ), + ("challenge".to_string(), "e".repeat(64)), + ("transport".to_string(), "lan".to_string()), + ("protocol".to_string(), "2".to_string()), + ]), + }; + socket + .send(Message::Text( + serde_json::to_string(&auth_required).unwrap(), + )) + .await + .unwrap(); + + tokio::time::timeout(Duration::from_secs(1), socket.next()) + .await + .expect("timed out waiting for pairing request") + .expect("Atem closed before pairing request") + .expect("failed to read pairing request"); + + fs::write(&server_target_path, "must remain unchanged").unwrap(); + symlink(&server_target_path, &server_session_path).unwrap(); + let granted = AstationMessage::StatusUpdate { + status: "auth".to_string(), + data: std::collections::HashMap::from([ + ("status".to_string(), "granted".to_string()), + ("session_id".to_string(), "session-new".to_string()), + ("token".to_string(), "token-new".to_string()), + ]), + }; + socket + .send(Message::Text(serde_json::to_string(&granted).unwrap())) + .await + .unwrap(); + }); + + let mut client = + AstationClient::new_with_test_state("atem-test-persist-failure", session_path); + let error = client + .connect(&format!("ws://{address}")) + .await + .expect_err("pairing unexpectedly ignored session persistence failure"); + assert!(error.to_string().contains("Failed to open private file")); + assert_eq!( + fs::read_to_string(target_path).unwrap(), + "must remain unchanged" + ); + assert!(!directory.path().join("relay-code").exists()); + server.await.unwrap(); + } + + #[cfg(unix)] + #[tokio::test] + async fn practical_websocket_reconnect_survives_session_refresh_failure() { + use std::os::unix::fs::symlink; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test Astation"); + let address = listener.local_addr().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let session_path = directory.path().join("sessions.json"); + let target_path = directory.path().join("protected-target"); + let astation_id = "astation-refresh-failure".to_string(); + + let mut sessions = crate::auth::SessionManager::default(); + sessions.insert_session(crate::auth::AuthSession::new( + "session-existing".to_string(), + "token-existing".to_string(), + astation_id.clone(), + "test-machine".to_string(), + )); + sessions.save_to(&session_path).unwrap(); + + let server_session_path = session_path.clone(); + let server_target_path = target_path.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener + .accept() + .await + .expect("test Astation did not accept"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("WebSocket handshake failed"); + let auth_required = AstationMessage::StatusUpdate { + status: "auth_required".to_string(), + data: std::collections::HashMap::from([ + ("astation_id".to_string(), astation_id), + ("challenge".to_string(), "f".repeat(64)), + ("transport".to_string(), "lan".to_string()), + ("protocol".to_string(), "2".to_string()), + ]), + }; + socket + .send(Message::Text( + serde_json::to_string(&auth_required).unwrap(), + )) + .await + .unwrap(); + + let request = tokio::time::timeout(Duration::from_secs(1), socket.next()) + .await + .expect("timed out waiting for session proof") + .expect("Atem closed before session proof") + .expect("failed to read session proof"); + assert!(matches!(request, Message::Text(_))); + + fs::remove_file(&server_session_path).unwrap(); + fs::write(&server_target_path, "must remain unchanged").unwrap(); + symlink(&server_target_path, &server_session_path).unwrap(); + let authenticated = AstationMessage::StatusUpdate { + status: "authenticated".to_string(), + data: std::collections::HashMap::from([( + "method".to_string(), + "session_proof".to_string(), + )]), + }; + socket + .send(Message::Text( + serde_json::to_string(&authenticated).unwrap(), + )) + .await + .unwrap(); + }); + + let mut client = + AstationClient::new_with_test_state("atem-test-refresh-failure", session_path); + client + .connect_without_pairing(&format!("ws://{address}")) + .await + .expect("session metadata failure aborted an authenticated reconnect"); + assert!(client.sender.is_some()); + assert_eq!( + fs::read_to_string(target_path).unwrap(), + "must remain unchanged" + ); + assert_eq!( + fs::read_to_string(directory.path().join("relay-code")).unwrap(), + "astation-refresh-failure" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn practical_websocket_rejects_protocol_downgrade_without_credentials() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test Astation"); + let address = listener.local_addr().unwrap(); + + let server = tokio::spawn(async move { + let (stream, _) = listener + .accept() + .await + .expect("test Astation did not accept"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("WebSocket handshake failed"); + let auth_required = AstationMessage::StatusUpdate { + status: "auth_required".to_string(), + data: std::collections::HashMap::from([ + ("astation_id".to_string(), "astation-downgrade".to_string()), + ("challenge".to_string(), "b".repeat(64)), + ("transport".to_string(), "lan".to_string()), + ]), + }; + socket + .send(Message::Text( + serde_json::to_string(&auth_required).unwrap(), + )) + .await + .unwrap(); + + match tokio::time::timeout(Duration::from_millis(250), socket.next()).await { + Err(_) | Ok(None) | Ok(Some(Err(_))) | Ok(Some(Ok(Message::Close(_)))) => {} + Ok(Some(Ok(Message::Text(text)))) => { + panic!("Atem leaked an authentication message after downgrade: {text}") + } + Ok(Some(other)) => panic!("unexpected WebSocket frame: {other:?}"), + } + }); + + let (_state, mut client) = isolated_client("atem-test-downgrade"); + client + .connect_raw(&format!("ws://{address}")) + .await + .unwrap(); + let error = client + .authenticate(Duration::from_secs(1), Duration::from_secs(1), true) + .await + .expect_err("protocol downgrade unexpectedly authenticated"); + assert!(error.to_string().contains("protocol v2 is required")); + drop(client); + server.await.unwrap(); + } + + #[tokio::test] + async fn practical_websocket_background_connect_never_starts_pairing() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test Astation"); + let address = listener.local_addr().unwrap(); + let astation_id = format!("astation-proof-only-{}", uuid::Uuid::new_v4()); + + let server = tokio::spawn(async move { + let (stream, _) = listener + .accept() + .await + .expect("test Astation did not accept"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("WebSocket handshake failed"); + let auth_required = AstationMessage::StatusUpdate { + status: "auth_required".to_string(), + data: std::collections::HashMap::from([ + ("astation_id".to_string(), astation_id), + ("challenge".to_string(), "d".repeat(64)), + ("transport".to_string(), "lan".to_string()), + ("protocol".to_string(), "2".to_string()), + ]), + }; + socket + .send(Message::Text( + serde_json::to_string(&auth_required).unwrap(), + )) + .await + .unwrap(); + + match tokio::time::timeout(Duration::from_millis(250), socket.next()).await { + Err(_) | Ok(None) | Ok(Some(Err(_))) | Ok(Some(Ok(Message::Close(_)))) => {} + Ok(Some(Ok(Message::Text(text)))) => { + panic!("background reconnect unexpectedly sent pairing data: {text}") + } + Ok(Some(other)) => panic!("unexpected WebSocket frame: {other:?}"), + } + }); + + let (_state, mut client) = isolated_client("atem-test-proof-only"); + let error = client + .connect_without_pairing(&format!("ws://{address}")) + .await + .expect_err("proof-only connection unexpectedly started pairing"); + assert_eq!(error.to_string(), "Pairing required; run 'atem pair'"); + server.await.unwrap(); + } + + #[tokio::test] + async fn practical_websocket_bounds_authentication_after_challenge() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test Astation"); + let address = listener.local_addr().unwrap(); + let astation_id = format!("astation-timeout-{}", uuid::Uuid::new_v4()); + + let server = tokio::spawn(async move { + let (stream, _) = listener + .accept() + .await + .expect("test Astation did not accept"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("WebSocket handshake failed"); + let auth_required = AstationMessage::StatusUpdate { + status: "auth_required".to_string(), + data: std::collections::HashMap::from([ + ("astation_id".to_string(), astation_id), + ("challenge".to_string(), "c".repeat(64)), + ("transport".to_string(), "lan".to_string()), + ("protocol".to_string(), "2".to_string()), + ]), + }; + socket + .send(Message::Text( + serde_json::to_string(&auth_required).unwrap(), + )) + .await + .unwrap(); + + let request = tokio::time::timeout(Duration::from_secs(1), socket.next()) + .await + .expect("timed out waiting for authentication request") + .expect("Atem closed before authentication request") + .expect("failed to read authentication request"); + assert!(matches!(request, Message::Text(_))); + tokio::time::sleep(Duration::from_millis(200)).await; + }); + + let (_state, mut client) = isolated_client("atem-test-timeout"); + client + .connect_raw(&format!("ws://{address}")) + .await + .unwrap(); + let error = client + .authenticate(Duration::from_secs(1), Duration::from_millis(50), true) + .await + .expect_err("silent Astation unexpectedly authenticated"); + assert_eq!(error.to_string(), "Authentication timed out"); + drop(client); + server.await.unwrap(); + } + // --- Serialization round-trip tests --- #[test]