From bf0c11d9d383d06630d8f77b1506546419765e74 Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 16:57:19 -0700 Subject: [PATCH 01/10] feat: add device authentication v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticate loopback, LAN, and identity-relay Atem clients with challenge proofs before registration or credential delivery. Add real WebSocket coverage, private storage, recursive log redaction, and an honest rollout/security specification. 🤖 Built with SMT --- README.md | 30 +- Sources/Menubar/AstationApp.swift | 18 +- Sources/Menubar/AstationHubManager.swift | 120 ++++- Sources/Menubar/AstationIdentity.swift | 11 +- Sources/Menubar/AstationWebSocketServer.swift | 258 +++++++---- Sources/Menubar/DeviceAuthentication.swift | 112 +++++ Sources/Menubar/Log.swift | 15 +- Sources/Menubar/NetworkDebugLogger.swift | 54 ++- Sources/Menubar/SessionStore.swift | 74 ++- .../DeviceAuthenticationTests.swift | 88 ++++ .../AstationTests/DirectConnectionTests.swift | 260 +++++++++++ .../NetworkDebugLoggerTests.swift | 19 + .../2026-07-21-device-authentication-v2.md | 133 ++++++ relay-server/README.md | 12 +- relay-server/SECURITY.md | 430 +++--------------- 15 files changed, 1137 insertions(+), 497 deletions(-) create mode 100644 Sources/Menubar/DeviceAuthentication.swift create mode 100644 Tests/AstationTests/DeviceAuthenticationTests.swift create mode 100644 Tests/AstationTests/DirectConnectionTests.swift create mode 100644 Tests/AstationTests/NetworkDebugLoggerTests.swift create mode 100644 docs/specs/2026-07-21-device-authentication-v2.md diff --git a/README.md b/README.md index 3e3a5b5..da052d5 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,19 @@ swift build -c release ## How It Works -Astation runs as a macOS menubar app with a WebSocket server. Multiple Atem instances connect to it, and the hub routes work to the focused (or first available) Atem. +Astation runs as a macOS menubar app with direct and relay WebSocket transports. Multiple Atem instances can use loopback, LAN, and relay connections concurrently, and the hub routes work to the focused (or first available) authenticated Atem. + +### Atem Connections + +| Atem location | Transport | First connection | Offline behavior | +|---------------|-----------|------------------|------------------| +| Same Mac | `ws://127.0.0.1:8080/ws` | Transparent same-user proof | Works with all radios disabled | +| Another LAN machine | `ws://:8080/ws` | User-approved pairing | Works without internet or relay | +| Remote network | Public `wss://` relay | User-approved pairing | Requires internet and relay | + +Loopback is identified from the socket peer address, not from a client-supplied header. LAN and relay clients receive a random challenge and must prove possession of their saved session token with HMAC-SHA256 before Astation registers the client or sends credentials. + +Direct LAN transport is currently plaintext WebSocket. The authentication protocol prevents session-ID-only impersonation, but LAN deployment is not production-ready until WSS certificate pinning is implemented. See [`docs/specs/2026-07-21-device-authentication-v2.md`](docs/specs/2026-07-21-device-authentication-v2.md). ### Mark Task Routing @@ -82,20 +94,20 @@ Messages carry only IDs, status, and descriptions -- no images or file lists flo | `markTaskNotify` | Chisel -> Astation | New task available (with summary for display) | | `markTaskAssignment` | Astation -> Atem | Route task to a specific Atem | | `markTaskResult` | Atem -> Astation | Report task completion/failure | -| `statusUpdate` | Astation -> Atem | Connection status on connect | +| `statusUpdate` | Astation <-> Atem | Authentication challenge, proof, and connection status | | `heartbeat` / `pong` | Atem <-> Astation | Keep-alive | | `voice_toggle` | Astation -> Atem | Voice input state | | `video_toggle` | Astation -> Atem | Video state | | `atem_instance_list` | Astation -> Atem | Broadcast connected peers | -| `auth_request` / `auth_response` | Atem <-> Astation | Authentication grant flow | - -### Auth Grant Flow +| `auth_request` / `auth_response` | Atem <-> Astation | Legacy browser/deep-link grant flow | -Atem instances authenticate via a deep-link flow: +### Device Authentication -1. Atem sends `auth_request` with session ID, hostname, and one-time password -2. Astation presents the request to the user for approval -3. On approval, sends `auth_response` with session token +1. Astation sends `auth_required` with its identity, connection scope, protocol version, and a fresh challenge. +2. Same-Mac Atems prove access to the `0600` bootstrap secret without an interactive prompt. +3. Paired LAN and relay Atems send `session_id`, `atem_id`, and an HMAC proof. The session token itself is never sent during reconnect. +4. An unknown device displays an eight-digit code and waits for explicit approval in Astation. +5. Astation processes application messages and sends account credentials only after authentication succeeds. ### Voice-Driven Coding diff --git a/Sources/Menubar/AstationApp.swift b/Sources/Menubar/AstationApp.swift index 9e8bf42..2b788e8 100644 --- a/Sources/Menubar/AstationApp.swift +++ b/Sources/Menubar/AstationApp.swift @@ -27,8 +27,9 @@ class AstationApp: NSObject, NSApplicationDelegate { mainMenu.addItem(editMenuItem) NSApp.mainMenu = mainMenu - // Initialize hub manager (business logic) - hubManager = AstationHubManager() + // Direct and relay transports must authenticate against the same devices. + let deviceSessionStore = SessionStore() + hubManager = AstationHubManager(deviceSessionStore: deviceSessionStore) // Initialize auth grant controller for deep-link auth flow authGrantController = AuthGrantController() @@ -45,18 +46,21 @@ class AstationApp: NSObject, NSApplicationDelegate { ) // Initialize WebSocket server - webSocketServer = AstationWebSocketServer(hubManager: hubManager) + webSocketServer = AstationWebSocketServer( + hubManager: hubManager, + sessionStore: deviceSessionStore + ) // Initialize status bar statusBarController = StatusBarController(hubManager: hubManager, webSocketServer: webSocketServer) - // Start WebSocket server on all interfaces (0.0.0.0) so LAN clients can connect + // One listener supports offline loopback and authenticated LAN clients concurrently. do { try webSocketServer.start(host: "0.0.0.0", port: 8080) let localIP = getLocalNetworkIP() ?? "127.0.0.1" Log.info("WebSocket server started on all interfaces (port 8080)") - Log.info(" Local: ws://127.0.0.1:8080") - Log.info(" Network: ws://\(localIP):8080") + Log.info(" Local (same-user): ws://127.0.0.1:8080/ws") + Log.info(" LAN (paired): ws://\(localIP):8080/ws") } catch { Log.error("Failed to start WebSocket server: \(error)") NSApp.terminate(nil) @@ -233,4 +237,4 @@ class AstationApp: NSObject, NSApplicationDelegate { Log.info(" Pair deep link received with code: \(code)") hubManager?.connectToRelay(code: code) } -} \ No newline at end of file +} diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index c636371..4a67e06 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -1,4 +1,5 @@ import CStationCore +import AppKit import Foundation import Network @@ -24,6 +25,7 @@ class AstationHubManager: ObservableObject { let apiClient = AgoraAPIClient() let rtcManager = RTCManager() let authGrantController = AuthGrantController() + let deviceSessionStore: SessionStore let timeSync = TimeSync() lazy var sessionLinkManager = SessionLinkManager(hubManager: self) lazy var voiceCodingManager = VoiceCodingManager(hubManager: self) @@ -40,6 +42,8 @@ class AstationHubManager: ObservableObject { /// NWPathMonitor for the identity relay — fires when network becomes available, /// enabling immediate reconnect without polling. Created once and reused. private var identityRelayPathMonitor: NWPathMonitor? + private var identityRelayAuthChallenges: [String: String] = [:] + private var authenticatedIdentityRelayClients: Set = [] /// Station relay URL. Priority: test override > ASTATION_RELAY_URL env var > UserDefaults > default. var stationRelayUrl: String { @@ -54,7 +58,8 @@ class AstationHubManager: ObservableObject { /// Set by AstationApp after wiring up the WebSocket server. var sendHandler: ((AstationMessage, String) -> Void)? - init(skipProjectLoad: Bool = false) { + init(skipProjectLoad: Bool = false, deviceSessionStore: SessionStore = SessionStore()) { + self.deviceSessionStore = deviceSessionStore self.tokenProvider = SsoTokenProvider( store: SsoSessionStore(), refresher: SsoNetworkRefresher(), @@ -1178,6 +1183,8 @@ class AstationHubManager: ObservableObject { self?.connectedClients .filter { $0.id.hasPrefix("relay-") } .forEach { self?.removeClient(withId: $0.id) } + self?.identityRelayAuthChallenges.removeAll() + self?.authenticatedIdentityRelayClients.removeAll() self?.identityRelayActive = false } // Schedule a 30s fallback retry (only if network is still up). @@ -1194,28 +1201,115 @@ class AstationHubManager: ObservableObject { } private func handleIdentityRelayMessage(_ msg: AstationMessage, task: URLSessionWebSocketTask, clientId: String) { - // When Atem connects to the identity relay room, it sends a "hello" to announce itself. - // We respond by registering it as a connected client (which sends credentials). if case .statusUpdate(let status, let data) = msg, status == "hello" { let hostname = data["hostname"] ?? "unknown" - Log.info("[AstationHub] Atem connected via identity relay from: \(hostname)") + let challenge = DeviceAuthentication.makeChallenge() + identityRelayAuthChallenges[clientId] = challenge + authenticatedIdentityRelayClients.remove(clientId) + sendHandler?(.statusUpdate(status: "auth_required", data: [ + "astation_id": AstationIdentity.shared.id, + "challenge": challenge, + "transport": "relay", + "protocol": DeviceAuthentication.protocolVersion, + "hostname": hostname + ]), clientId) + Log.info("[AstationHub] Relay authentication required for \(hostname)") + return + } - let client = ConnectedClient( - id: clientId, - clientType: "Atem", - connectedAt: Date(), - hostname: "relay:\(hostname)" - ) - // addClient calls sendCredentials(toClientId: clientId) via sendHandler - addClient(client) + if !authenticatedIdentityRelayClients.contains(clientId) { + handleIdentityRelayAuthentication(msg, clientId: clientId) return } - // Forward all other messages through the hub and send any response back if let response = handleMessage(msg, from: clientId) { sendHandler?(response, clientId) } } + + private func handleIdentityRelayAuthentication(_ msg: AstationMessage, clientId: String) { + guard case .statusUpdate(let status, let data) = msg, + status == "auth", + let challenge = identityRelayAuthChallenges[clientId] else { + Log.warn("[AstationHub] Dropped unauthenticated relay message from \(clientId)") + return + } + + if let sessionId = data["session_id"], + let atemId = data["atem_id"], + let proof = data["proof"], + let session = deviceSessionStore.authenticate( + sessionId: sessionId, + atemId: atemId, + challenge: challenge, + proof: proof, + astationId: AstationIdentity.shared.id + ) { + finishIdentityRelayAuthentication( + clientId: clientId, + hostname: session.hostname, + response: .statusUpdate(status: "authenticated", data: [ + "method": "session_proof", + "session_id": sessionId, + "protocol": DeviceAuthentication.protocolVersion + ]) + ) + return + } + + if data["session_id"] != nil { + sendHandler?(.error(message: "Session proof invalid - pairing required"), clientId) + return + } + + guard let pairingCode = data["pairing_code"], + let hostname = data["hostname"], + let atemId = data["atem_id"] else { + sendHandler?(.error(message: "Invalid relay authentication credentials"), clientId) + return + } + + let alert = NSAlert() + alert.messageText = "Remote Atem Pairing Request" + alert.informativeText = "Device: \(hostname)\nCode: \(pairingCode)\n\nAllow this Atem to connect through the relay?" + alert.addButton(withTitle: "Allow") + alert.addButton(withTitle: "Deny") + alert.alertStyle = .informational + + guard alert.runModal() == .alertFirstButtonReturn else { + sendHandler?(.auth(info: ["status": "denied", "message": "Pairing denied by user"]), clientId) + return + } + + let session = deviceSessionStore.create(hostname: hostname, atemId: atemId) + finishIdentityRelayAuthentication( + clientId: clientId, + hostname: hostname, + response: .auth(info: [ + "status": "granted", + "session_id": session.id, + "token": session.token, + "protocol": DeviceAuthentication.protocolVersion + ]) + ) + } + + private func finishIdentityRelayAuthentication( + clientId: String, + hostname: String, + response: AstationMessage + ) { + identityRelayAuthChallenges.removeValue(forKey: clientId) + authenticatedIdentityRelayClients.insert(clientId) + sendHandler?(response, clientId) + addClient(ConnectedClient( + id: clientId, + clientType: "Atem", + connectedAt: Date(), + hostname: "relay:\(hostname)" + )) + Log.info("[AstationHub] Authenticated relay Atem: \(hostname)") + } } // MARK: - Data Models diff --git a/Sources/Menubar/AstationIdentity.swift b/Sources/Menubar/AstationIdentity.swift index 4ed9f5d..372566b 100644 --- a/Sources/Menubar/AstationIdentity.swift +++ b/Sources/Menubar/AstationIdentity.swift @@ -26,14 +26,23 @@ class AstationIdentity { try FileManager.default.createDirectory( at: path.deletingLastPathComponent(), withIntermediateDirectories: true, - attributes: nil + attributes: [.posixPermissions: 0o700] ) try id.write(to: path, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path.path + ) Log.info("Generated new Astation identity: \(id)") } catch { Log.error("Failed to save Astation identity: \(error)") } } + + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path.path + ) } /// Path to identity file: ~/Library/Application Support/Astation/identity.txt diff --git a/Sources/Menubar/AstationWebSocketServer.swift b/Sources/Menubar/AstationWebSocketServer.swift index 4173dcc..5c01981 100644 --- a/Sources/Menubar/AstationWebSocketServer.swift +++ b/Sources/Menubar/AstationWebSocketServer.swift @@ -10,11 +10,22 @@ class AstationWebSocketServer { private var channel: Channel? private let hubManager: AstationHubManager private var connectedClients: [String: WebSocket] = [:] - private let sessionStore = SessionStore() + private let sessionStore: SessionStore + private let localBootstrapStore: LocalBootstrapStore? private var authenticatedClients: Set = [] // Client IDs that have been authenticated + private var pendingAuthentication: [String: DirectAuthenticationContext] = [:] - init(hubManager: AstationHubManager) { + init( + hubManager: AstationHubManager, + sessionStore: SessionStore = SessionStore(), + localBootstrapStore: LocalBootstrapStore? = nil + ) { self.hubManager = hubManager + self.sessionStore = sessionStore + self.localBootstrapStore = localBootstrapStore ?? (try? LocalBootstrapStore()) + if self.localBootstrapStore == nil { + Log.error("Local Atem authentication is unavailable: bootstrap secret could not be loaded") + } } func start(host: String, port: Int) throws { @@ -25,8 +36,9 @@ class AstationWebSocketServer { return channel.eventLoop.makeSucceededFuture(HTTPHeaders()) }, upgradePipelineHandler: { channel, _ in + let scope = DirectConnectionScope(peerAddress: channel.remoteAddress?.ipAddress) return WebSocket.server(on: channel) { ws in - self.handleWebSocketConnection(ws) + self.handleWebSocketConnection(ws, scope: scope) } } ) @@ -62,11 +74,16 @@ class AstationWebSocketServer { Log.info("WebSocket server stopped") } - private func handleWebSocketConnection(_ ws: WebSocket) { + private func handleWebSocketConnection(_ ws: WebSocket, scope: DirectConnectionScope) { let clientId = UUID().uuidString connectedClients[clientId] = ws + let challenge = DeviceAuthentication.makeChallenge() + pendingAuthentication[clientId] = DirectAuthenticationContext( + scope: scope, + challenge: challenge + ) - Log.info("🔌 New WebSocket connection: \(clientId.prefix(8))") + Log.info("New \(scope.rawValue) WebSocket connection: \(clientId.prefix(8))") // Handle incoming messages ws.onText { ws, text in @@ -81,8 +98,9 @@ class AstationWebSocketServer { ws.onClose.whenComplete { _ in self.connectedClients.removeValue(forKey: clientId) self.authenticatedClients.remove(clientId) + self.pendingAuthentication.removeValue(forKey: clientId) self.hubManager.removeClient(withId: clientId) - Log.info("🔌 WebSocket connection closed: \(clientId.prefix(8))") + Log.info("WebSocket connection closed: \(clientId.prefix(8))") } // Send auth challenge - client must respond with session or pairing code @@ -90,7 +108,10 @@ class AstationWebSocketServer { status: "auth_required", data: [ "clientId": clientId, - "astation_id": AstationIdentity.shared.id + "astation_id": AstationIdentity.shared.id, + "challenge": challenge, + "transport": scope.rawValue, + "protocol": DeviceAuthentication.protocolVersion ] ) sendMessage(authChallenge, to: clientId) @@ -105,12 +126,6 @@ class AstationWebSocketServer { return } - // Handle session verification requests (from relay server) - if case .statusUpdate(let status, let messageData) = message, status == "session_verify_request" { - handleSessionVerifyRequest(messageData, from: clientId) - return - } - // Check if client is authenticated if !authenticatedClients.contains(clientId) { // Client not authenticated - check if this is an auth message @@ -118,6 +133,11 @@ class AstationWebSocketServer { return } + if case .statusUpdate(let status, let messageData) = message, status == "session_verify_request" { + handleSessionVerifyRequest(messageData, from: clientId) + return + } + // Client is authenticated - refresh session activity if case .statusUpdate(let status, let messageData) = message { if status == "auth", let sessionId = messageData["session_id"] { @@ -138,51 +158,63 @@ class AstationWebSocketServer { Log.warn("⚠️ Unauthenticated client \(clientId.prefix(8)) sent non-auth message - rejecting") let errorMsg = AstationMessage.error(message: "Authentication required") sendMessage(errorMsg, to: clientId) - ws.close(code: .policyViolation) + _ = ws.close(code: .policyViolation) return } - // Check for session-based auth - if let sessionId = authInfo["session_id"] as? String { - if sessionStore.validate(sessionId: sessionId) { - // Session valid - authenticate client - authenticateClient(clientId, sessionId: sessionId) + guard let context = pendingAuthentication[clientId] else { + sendMessage(.error(message: "Authentication challenge expired"), to: clientId) + _ = ws.close(code: .policyViolation) + return + } - // Add to hub manager - if let hostname = sessionStore.get(sessionId: sessionId)?.hostname { - let client = ConnectedClient( - id: clientId, - clientType: "Atem", - connectedAt: Date(), - hostname: hostname - ) - hubManager.addClient(client) - } + if context.scope == .loopback { + authenticateLoopback(authInfo, context: context, clientId: clientId, ws: ws) + return + } + + if let sessionId = authInfo["session_id"], + let atemId = authInfo["atem_id"], + let proof = authInfo["proof"], + let session = sessionStore.authenticate( + sessionId: sessionId, + atemId: atemId, + challenge: context.challenge, + proof: proof, + astationId: AstationIdentity.shared.id + ) { + authenticateClient(clientId, sessionId: sessionId) + pendingAuthentication.removeValue(forKey: clientId) - // Send success response let successMsg = AstationMessage.statusUpdate( status: "authenticated", - data: ["method": "session"] + data: [ + "method": "session_proof", + "session_id": sessionId, + "protocol": DeviceAuthentication.protocolVersion + ] ) sendMessage(successMsg, to: clientId) + registerClient(clientId, hostname: session.hostname) - Log.info("✅ Client \(clientId.prefix(8)) authenticated via session") + Log.info("Client \(clientId.prefix(8)) authenticated via LAN session proof") return - } else { - // Session invalid or expired - Log.warn("❌ Invalid/expired session from \(clientId.prefix(8))") - let errorMsg = AstationMessage.error(message: "Session expired - pairing required") - sendMessage(errorMsg, to: clientId) - ws.close(code: .policyViolation) - return - } + } else if authInfo["session_id"] != nil { + Log.warn("Invalid session proof from LAN client \(clientId.prefix(8))") + sendMessage(.error(message: "Session proof invalid - pairing required"), to: clientId) + return } - // Check for pairing-based auth - if let pairingCode = authInfo["pairing_code"] as? String, - let hostname = authInfo["hostname"] as? String { - // Show pairing dialog to user - showPairingDialog(code: pairingCode, hostname: hostname, clientId: clientId) + if let pairingCode = authInfo["pairing_code"], + let hostname = authInfo["hostname"], + let atemId = authInfo["atem_id"] { + showPairingDialog( + code: pairingCode, + hostname: hostname, + atemId: atemId, + clientId: clientId, + ws: ws + ) return } @@ -190,7 +222,54 @@ class AstationWebSocketServer { Log.warn("⚠️ Client \(clientId.prefix(8)) sent invalid auth message") let errorMsg = AstationMessage.error(message: "Invalid auth credentials") sendMessage(errorMsg, to: clientId) - ws.close(code: .policyViolation) + _ = ws.close(code: .policyViolation) + } + + private func authenticateLoopback( + _ authInfo: [String: String], + context: DirectAuthenticationContext, + clientId: String, + ws: WebSocket + ) { + guard let store = localBootstrapStore, + let atemId = authInfo["atem_id"], + let hostname = authInfo["hostname"], + let proof = authInfo["proof"], + DeviceAuthentication.verify( + proof: proof, + token: store.token, + challenge: context.challenge, + astationId: AstationIdentity.shared.id, + atemId: atemId, + sessionId: "local" + ) else { + Log.warn("Invalid same-user proof from loopback client \(clientId.prefix(8))") + sendMessage(.error(message: "Local authentication failed"), to: clientId) + _ = ws.close(code: .policyViolation) + return + } + + let session = sessionStore.createOrRefreshLocal(hostname: hostname, atemId: atemId) + authenticatedClients.insert(clientId) + pendingAuthentication.removeValue(forKey: clientId) + sendMessage(.auth(info: [ + "status": "granted", + "method": "local_proof", + "session_id": session.id, + "token": session.token, + "protocol": DeviceAuthentication.protocolVersion + ]), to: clientId) + registerClient(clientId, hostname: hostname) + Log.info("Loopback client \(clientId.prefix(8)) authenticated without interactive pairing") + } + + private func registerClient(_ clientId: String, hostname: String) { + hubManager.addClient(ConnectedClient( + id: clientId, + clientType: "Atem", + connectedAt: Date(), + hostname: hostname + )) } private func authenticateClient(_ clientId: String, sessionId: String) { @@ -212,7 +291,7 @@ class AstationWebSocketServer { // Get astation_id if session is valid var astationId: String? = nil - if isValid, let sessionInfo = sessionStore.get(sessionId: sessionId) { + if isValid, sessionStore.get(sessionId: sessionId) != nil { astationId = AstationIdentity.shared.id // Refresh the session since it's being used sessionStore.refresh(sessionId: sessionId) @@ -239,7 +318,13 @@ class AstationWebSocketServer { Log.info("✅ Session verification response sent: valid=\(isValid)") } - private func showPairingDialog(code: String, hostname: String, clientId: String) { + private func showPairingDialog( + code: String, + hostname: String, + atemId: String, + clientId: String, + ws: WebSocket + ) { // Show pairing approval dialog on main thread DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -258,41 +343,24 @@ class AstationWebSocketServer { let response = alert.runModal() - if response == .alertFirstButtonReturn { - // User approved - create session - let session = self.sessionStore.create(hostname: hostname) - - // Authenticate client - self.authenticatedClients.insert(clientId) - - // Add to hub manager - let client = ConnectedClient( - id: clientId, - clientType: "Atem", - connectedAt: Date(), - hostname: hostname - ) - self.hubManager.addClient(client) - - // Send success with session info - let successMsg = AstationMessage.auth(info: [ - "status": "granted", - "session_id": session.id, - "token": session.token - ]) - self.sendMessage(successMsg, to: clientId) - - Log.info("✅ Pairing approved for \(hostname) (\(clientId.prefix(8)))") - } else { - // User denied - let errorMsg = AstationMessage.error(message: "Pairing denied by user") - self.sendMessage(errorMsg, to: clientId) - - if let ws = self.connectedClients[clientId] { - ws.close(code: .policyViolation) + ws.eventLoop.execute { + guard self.connectedClients[clientId] != nil else { return } + if response == .alertFirstButtonReturn { + let session = self.sessionStore.create(hostname: hostname, atemId: atemId) + self.authenticatedClients.insert(clientId) + self.pendingAuthentication.removeValue(forKey: clientId) + self.sendMessage(.auth(info: [ + "status": "granted", + "session_id": session.id, + "token": session.token + ]), to: clientId) + self.registerClient(clientId, hostname: hostname) + Log.info("✅ Pairing approved for \(hostname) (\(clientId.prefix(8)))") + } else { + self.sendMessage(.error(message: "Pairing denied by user"), to: clientId) + _ = ws.close(code: .policyViolation) + Log.info("❌ Pairing denied for \(hostname) (\(clientId.prefix(8)))") } - - Log.info("❌ Pairing denied for \(hostname) (\(clientId.prefix(8)))") } } } @@ -320,7 +388,8 @@ class AstationWebSocketServer { return } - for (clientId, ws) in connectedClients { + for clientId in authenticatedClients { + guard let ws = connectedClients[clientId] else { continue } ws.send(text) NetworkDebugLogger.logWebSocket(direction: "send", context: "local \(clientId)", message: text) } @@ -329,6 +398,29 @@ class AstationWebSocketServer { func getConnectedClientsCount() -> Int { return connectedClients.count } + + var listeningPort: Int? { + channel?.localAddress?.port + } +} + +private enum DirectConnectionScope: String { + case loopback + case lan + + init(peerAddress: String?) { + switch peerAddress?.lowercased() { + case "127.0.0.1", "::1", "0:0:0:0:0:0:0:1", "::ffff:127.0.0.1": + self = .loopback + default: + self = .lan + } + } +} + +private struct DirectAuthenticationContext { + let scope: DirectConnectionScope + let challenge: String } // Simple HTTP handler for WebSocket upgrade diff --git a/Sources/Menubar/DeviceAuthentication.swift b/Sources/Menubar/DeviceAuthentication.swift new file mode 100644 index 0000000..9aaa3a3 --- /dev/null +++ b/Sources/Menubar/DeviceAuthentication.swift @@ -0,0 +1,112 @@ +import CryptoKit +import Foundation + +enum DeviceAuthentication { + static let protocolVersion = "2" + + static func makeChallenge() -> String { + randomHex(byteCount: 32) + } + + static func proof( + token: String, + challenge: String, + astationId: String, + atemId: String, + sessionId: String + ) -> String { + let message = canonicalMessage( + challenge: challenge, + astationId: astationId, + atemId: atemId, + sessionId: sessionId + ) + let key = SymmetricKey(data: Data(token.utf8)) + let code = HMAC.authenticationCode(for: Data(message.utf8), using: key) + return code.map { String(format: "%02x", $0) }.joined() + } + + static func verify( + proof candidate: String, + token: String, + challenge: String, + astationId: String, + atemId: String, + sessionId: String + ) -> Bool { + let expected = proof( + token: token, + challenge: challenge, + astationId: astationId, + atemId: atemId, + sessionId: sessionId + ) + return constantTimeEqual(candidate.lowercased(), expected) + } + + private static func canonicalMessage( + challenge: String, + astationId: String, + atemId: String, + sessionId: String + ) -> String { + "astation-auth-v2\n\(challenge)\n\(astationId)\n\(atemId)\n\(sessionId)" + } + + private static func constantTimeEqual(_ lhs: String, _ rhs: String) -> Bool { + let left = Array(lhs.utf8) + let right = Array(rhs.utf8) + guard left.count == right.count else { return false } + var difference: UInt8 = 0 + for index in left.indices { + difference |= left[index] ^ right[index] + } + return difference == 0 + } + + fileprivate static func randomHex(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + return UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + } + return bytes.map { String(format: "%02x", $0) }.joined() + } +} + +/// A same-user secret shared by Astation and local Atem processes. It removes +/// interactive pairing on loopback without trusting arbitrary browser pages. +final class LocalBootstrapStore { + static let filename = "local-bootstrap-token" + + let token: String + let fileURL: URL + + init(directory: URL? = nil) throws { + let fileManager = FileManager.default + let baseDirectory = directory ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("Astation", isDirectory: true) + + try fileManager.createDirectory( + at: baseDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: baseDirectory.path) + + fileURL = baseDirectory.appendingPathComponent(Self.filename) + if let existing = try? String(contentsOf: fileURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty { + token = existing + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + return + } + + let generated = DeviceAuthentication.randomHex(byteCount: 32) + try Data((generated + "\n").utf8).write(to: fileURL, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + token = generated + } +} diff --git a/Sources/Menubar/Log.swift b/Sources/Menubar/Log.swift index e9a5908..122167b 100644 --- a/Sources/Menubar/Log.swift +++ b/Sources/Menubar/Log.swift @@ -20,7 +20,12 @@ enum Log { static func setup() { let fm = FileManager.default // Ensure directory exists - try? fm.createDirectory(at: logDir, withIntermediateDirectories: true) + try? fm.createDirectory( + at: logDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: logDir.path) // Rotate if too large (> 2 MB) if let attrs = try? fm.attributesOfItem(atPath: logFile.path), @@ -28,12 +33,18 @@ enum Log { let oldFile = logDir.appendingPathComponent("astation.old.log") try? fm.removeItem(at: oldFile) try? fm.moveItem(at: logFile, to: oldFile) + try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: oldFile.path) } // Create file if needed if !fm.fileExists(atPath: logFile.path) { - fm.createFile(atPath: logFile.path, contents: nil) + fm.createFile( + atPath: logFile.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) } + try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: logFile.path) fileHandle = FileHandle(forWritingAtPath: logFile.path) fileHandle?.seekToEndOfFile() diff --git a/Sources/Menubar/NetworkDebugLogger.swift b/Sources/Menubar/NetworkDebugLogger.swift index a05a669..ae05e3f 100644 --- a/Sources/Menubar/NetworkDebugLogger.swift +++ b/Sources/Menubar/NetworkDebugLogger.swift @@ -16,7 +16,7 @@ enum NetworkDebugLogger { static func logRequest(_ request: URLRequest, bodyOverride: Data? = nil, label: String? = nil) { guard isEnabled else { return } let method = request.httpMethod ?? "GET" - let url = request.url?.absoluteString ?? "(nil)" + let url = sanitizeURL(request.url) let headers = sanitizeHeaders(request.allHTTPHeaderFields ?? [:]) let bodyData = bodyOverride ?? request.httpBody let body = formatBody(bodyData) @@ -45,7 +45,7 @@ enum NetworkDebugLogger { static func logWebSocket(direction: String, context: String, message: String) { guard isEnabled else { return } - Log.debug("[WS] \(direction) \(context): \(truncate(message))") + Log.debug("[WS] \(direction) \(context): \(sanitizedPayload(message))") } static func logWebSocketBinary(direction: String, context: String, size: Int) { @@ -81,7 +81,7 @@ enum NetworkDebugLogger { guard let data else { return "" } if data.isEmpty { return "" } if let text = String(data: data, encoding: .utf8) { - return truncate(text) + return sanitizedPayload(text) } return "" } @@ -92,4 +92,52 @@ enum NetworkDebugLogger { let remaining = text.count - maxPayloadLength return "\(prefix)…" } + + static func sanitizedPayload(_ text: String) -> String { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let sanitizedData = try? JSONSerialization.data( + withJSONObject: sanitizeJSONObject(object), + options: [.sortedKeys] + ), + let sanitized = String(data: sanitizedData, encoding: .utf8) else { + return truncate(text) + } + return truncate(sanitized) + } + + private static let sensitiveKeys: Set = [ + "access_token", "api_key", "app_certificate", "authorization", + "cookie", "credential", "encryption_key", "pairing_code", "password", + "proof", "refresh_token", "secret", "session", "session_id", "token" + ] + + private static func sanitizeJSONObject(_ value: Any) -> Any { + if let dictionary = value as? [String: Any] { + return dictionary.reduce(into: [String: Any]()) { result, entry in + let normalizedKey = entry.key.lowercased().replacingOccurrences(of: "-", with: "_") + result[entry.key] = sensitiveKeys.contains(normalizedKey) + ? "" + : sanitizeJSONObject(entry.value) + } + } + if let array = value as? [Any] { + return array.map(sanitizeJSONObject) + } + return value + } + + private static func sanitizeURL(_ url: URL?) -> String { + guard let url else { return "(nil)" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let items = components.queryItems else { + return url.absoluteString + } + components.queryItems = items.map { item in + let normalizedName = item.name.lowercased().replacingOccurrences(of: "-", with: "_") + guard sensitiveKeys.contains(normalizedName) else { return item } + return URLQueryItem(name: item.name, value: "") + } + return components.string ?? url.absoluteString + } } diff --git a/Sources/Menubar/SessionStore.swift b/Sources/Menubar/SessionStore.swift index 00987ae..2f39b9e 100644 --- a/Sources/Menubar/SessionStore.swift +++ b/Sources/Menubar/SessionStore.swift @@ -5,6 +5,7 @@ import Foundation struct SessionInfo: Codable { let id: String let hostname: String + var atemId: String? var lastActivity: Date let token: String let createdAt: Date @@ -29,15 +30,18 @@ class SessionStore { private let storePath: URL private let queue = DispatchQueue(label: "build.agora.SessionStore", attributes: .concurrent) - init() { - // Store sessions in ~/Library/Application Support/Astation/sessions.json + init(storageURL: URL? = nil) { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let astation = appSupport.appendingPathComponent("Astation") - // Create directory if needed - try? FileManager.default.createDirectory(at: astation, withIntermediateDirectories: true) + try? FileManager.default.createDirectory( + at: astation, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: astation.path) - storePath = astation.appendingPathComponent("sessions.json") + storePath = storageURL ?? astation.appendingPathComponent("sessions.json") // Load existing sessions loadFromDisk() @@ -80,11 +84,12 @@ class SessionStore { } /// Create a new session after pairing approval. - func create(hostname: String) -> SessionInfo { + func create(hostname: String, atemId: String? = nil) -> SessionInfo { return queue.sync(flags: .barrier) { let session = SessionInfo( id: UUID().uuidString, hostname: hostname, + atemId: atemId, lastActivity: Date(), token: generateToken(), createdAt: Date() @@ -101,6 +106,59 @@ class SessionStore { } } + /// Authenticate a device by proving possession of its session token. + /// Legacy sessions are bound to the first atem_id that proves the token. + func authenticate( + sessionId: String, + atemId: String, + challenge: String, + proof: String, + astationId: String + ) -> SessionInfo? { + queue.sync(flags: .barrier) { + guard var session = sessions[sessionId], session.isValid else { return nil } + guard session.atemId == nil || session.atemId == atemId else { return nil } + guard DeviceAuthentication.verify( + proof: proof, + token: session.token, + challenge: challenge, + astationId: astationId, + atemId: atemId, + sessionId: sessionId + ) else { return nil } + + session.atemId = atemId + session.lastActivity = Date() + sessions[sessionId] = session + saveToDisk() + return session + } + } + + /// Return one stable device session for a locally authenticated Atem. + func createOrRefreshLocal(hostname: String, atemId: String) -> SessionInfo { + queue.sync(flags: .barrier) { + if var session = sessions.values.first(where: { $0.atemId == atemId && $0.isValid }) { + session.lastActivity = Date() + sessions[session.id] = session + saveToDisk() + return session + } + + let session = SessionInfo( + id: UUID().uuidString, + hostname: hostname, + atemId: atemId, + lastActivity: Date(), + token: generateToken(), + createdAt: Date() + ) + sessions[session.id] = session + saveToDisk() + return session + } + } + /// Delete a specific session. func delete(sessionId: String) { queue.async(flags: .barrier) { @@ -153,7 +211,8 @@ class SessionStore { encoder.outputFormatting = .prettyPrinted let data = try encoder.encode(sessions) - try data.write(to: storePath) + try data.write(to: storePath, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: storePath.path) Log.debug("💾 Sessions saved to disk (\(sessions.count) total)") } catch { @@ -207,6 +266,7 @@ extension SessionStore { let session = SessionInfo( id: id, hostname: hostname, + atemId: nil, lastActivity: lastActivity, token: generateToken(), createdAt: lastActivity diff --git a/Tests/AstationTests/DeviceAuthenticationTests.swift b/Tests/AstationTests/DeviceAuthenticationTests.swift new file mode 100644 index 0000000..242ca4a --- /dev/null +++ b/Tests/AstationTests/DeviceAuthenticationTests.swift @@ -0,0 +1,88 @@ +import Foundation +import XCTest +@testable import Menubar + +final class DeviceAuthenticationTests: XCTestCase { + func testProofMatchesProtocolVector() { + let proof = DeviceAuthentication.proof( + token: "token-abc", + challenge: "challenge-123", + astationId: "astation-home", + atemId: "atem-office", + sessionId: "session-456" + ) + + XCTAssertEqual(proof, "9fde5ba861c1a159d377b89e6fb3f92d245795998af958f5db3ad343d589d0ba") + XCTAssertTrue(DeviceAuthentication.verify( + proof: proof, + token: "token-abc", + challenge: "challenge-123", + astationId: "astation-home", + atemId: "atem-office", + sessionId: "session-456" + )) + XCTAssertFalse(DeviceAuthentication.verify( + proof: proof, + token: "wrong-token", + challenge: "challenge-123", + astationId: "astation-home", + atemId: "atem-office", + sessionId: "session-456" + )) + } + + func testBootstrapSecretIsStableAndPrivate() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AstationBootstrapTests-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + + let first = try LocalBootstrapStore(directory: directory) + let second = try LocalBootstrapStore(directory: directory) + XCTAssertEqual(first.token, second.token) + XCTAssertEqual(first.token.count, 64) + + let directoryMode = try FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? NSNumber + let fileMode = try FileManager.default.attributesOfItem(atPath: first.fileURL.path)[.posixPermissions] as? NSNumber + XCTAssertEqual(directoryMode?.intValue, 0o700) + XCTAssertEqual(fileMode?.intValue, 0o600) + } + + func testSessionRequiresTokenProofAndMatchingDevice() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AstationSessionTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = SessionStore(storageURL: directory.appendingPathComponent("sessions.json")) + let session = store.create(hostname: "office", atemId: "atem-office") + let proof = DeviceAuthentication.proof( + token: session.token, + challenge: "nonce", + astationId: "astation-home", + atemId: "atem-office", + sessionId: session.id + ) + + XCTAssertNotNil(store.authenticate( + sessionId: session.id, + atemId: "atem-office", + challenge: "nonce", + proof: proof, + astationId: "astation-home" + )) + XCTAssertNil(store.authenticate( + sessionId: session.id, + atemId: "atem-other", + challenge: "nonce", + proof: proof, + astationId: "astation-home" + )) + XCTAssertNil(store.authenticate( + sessionId: session.id, + atemId: "atem-office", + challenge: "new-nonce", + proof: proof, + astationId: "astation-home" + )) + } +} diff --git a/Tests/AstationTests/DirectConnectionTests.swift b/Tests/AstationTests/DirectConnectionTests.swift new file mode 100644 index 0000000..47787ab --- /dev/null +++ b/Tests/AstationTests/DirectConnectionTests.swift @@ -0,0 +1,260 @@ +import Foundation +import NIO +import WebSocketKit +import XCTest +@testable import Menubar + +final class DirectConnectionTests: XCTestCase { + func testLoopbackAuthenticatesWithoutPairingOrNetwork() throws { + let fixture = try DirectServerFixture(host: "127.0.0.1") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + + let future = connect( + host: "127.0.0.1", + fixture: fixture, + group: group, + authMessage: loopbackAuthMessage(fixture: fixture, atemId: "local-test-atem") + ) + let (result, socket) = try future.wait() + XCTAssertEqual(result, "authenticated:local_proof") + _ = socket.close() + } + + func testInvalidLoopbackProofIsRejected() throws { + let fixture = try DirectServerFixture(host: "127.0.0.1") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + + let future = connect( + host: "127.0.0.1", + fixture: fixture, + group: group, + authMessage: { _ in + .statusUpdate(status: "auth", data: [ + "method": "local_proof", + "atem_id": "forged-atem", + "hostname": "test-mac", + "proof": "not-a-valid-proof" + ]) + } + ) + let (result, _) = try future.wait() + XCTAssertEqual(result, "error:Local authentication failed") + } + + func testFiveLoopbackClientsAuthenticateConcurrently() throws { + let fixture = try DirectServerFixture(host: "127.0.0.1") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 2) + defer { try? group.syncShutdownGracefully() } + + let connections = (0..<5).map { index in + connect( + host: "127.0.0.1", + fixture: fixture, + group: group, + authMessage: loopbackAuthMessage( + fixture: fixture, + atemId: "local-test-atem-\(index)" + ) + ) + } + let results = try EventLoopFuture.whenAllSucceed(connections, on: group.next()).wait() + + XCTAssertEqual(results.map(\.0), Array(repeating: "authenticated:local_proof", count: 5)) + XCTAssertEqual(fixture.server.getConnectedClientsCount(), 5) + results.forEach { _ = $0.1.close() } + } + + func testPairedLANClientAuthenticatesDirectlyWithoutRelay() throws { + guard let lanAddress = Self.nonLoopbackIPv4Address() else { + throw XCTSkip("No non-loopback IPv4 interface is available") + } + let fixture = try DirectServerFixture(host: "0.0.0.0") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + + let atemId = "office-atem" + let session = fixture.sessions.create(hostname: "office", atemId: atemId) + let future = connect( + host: lanAddress, + fixture: fixture, + group: group, + authMessage: { challengeData in + XCTAssertEqual(challengeData["transport"], "lan") + let challenge = challengeData["challenge"] ?? "" + let astationId = challengeData["astation_id"] ?? "" + let proof = DeviceAuthentication.proof( + token: session.token, + challenge: challenge, + astationId: astationId, + atemId: atemId, + sessionId: session.id + ) + return .statusUpdate(status: "auth", data: [ + "session_id": session.id, + "atem_id": atemId, + "proof": proof + ]) + } + ) + let (result, socket) = try future.wait() + + XCTAssertEqual(result, "authenticated:session_proof") + _ = socket.close() + } + + func testUnauthenticatedClientDoesNotReceiveApplicationBroadcast() throws { + let fixture = try DirectServerFixture(host: "127.0.0.1") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + + let result = group.next().makePromise(of: (Bool, WebSocket).self) + var challengeReceived = false + var broadcastReceived = false + let connection = WebSocket.connect( + to: "ws://127.0.0.1:\(fixture.port)/ws", + on: group + ) { socket in + socket.onText { socket, text in + guard let data = text.data(using: .utf8), + let message = try? JSONDecoder().decode(AstationMessage.self, from: data) else { + return + } + switch message { + case .statusUpdate(let status, _) where status == "auth_required" && !challengeReceived: + challengeReceived = true + fixture.server.broadcastMessage(.videoToggle(active: true)) + socket.eventLoop.scheduleTask(in: .milliseconds(100)) { + result.succeed((broadcastReceived, socket)) + } + case .videoToggle: + broadcastReceived = true + default: + break + } + } + } + connection.cascadeFailure(to: result) + + let (received, socket) = try result.futureResult.wait() + XCTAssertFalse(received) + _ = socket.close() + } + + private func connect( + host: String, + fixture: DirectServerFixture, + group: EventLoopGroup, + authMessage: @escaping ([String: String]) -> AstationMessage + ) -> EventLoopFuture<(String, WebSocket)> { + let result = group.next().makePromise(of: (String, WebSocket).self) + let connection = WebSocket.connect( + to: "ws://\(host):\(fixture.port)/ws", + on: group + ) { socket in + socket.onText { socket, text in + guard let data = text.data(using: .utf8), + let message = try? JSONDecoder().decode(AstationMessage.self, from: data) else { + return + } + switch message { + case .statusUpdate(let status, let data) where status == "auth_required": + let response = authMessage(data) + guard let encoded = try? JSONEncoder().encode(response), + let responseText = String(data: encoded, encoding: .utf8) else { + return result.fail(DirectTestFailure.encodingFailed) + } + socket.send(responseText) + case .statusUpdate(let status, let data) where status == "auth": + result.succeed(("authenticated:\(data["method"] ?? "unknown")", socket)) + case .statusUpdate(let status, let data) where status == "authenticated": + result.succeed(("authenticated:\(data["method"] ?? "unknown")", socket)) + case .statusUpdate(let status, let data) where status == "error": + result.succeed(("error:\(data["message"] ?? "unknown")", socket)) + default: + break + } + } + } + connection.cascadeFailure(to: result) + return result.futureResult + } + + private func loopbackAuthMessage( + fixture: DirectServerFixture, + atemId: String + ) -> ([String: String]) -> AstationMessage { + { challengeData in + XCTAssertEqual(challengeData["transport"], "loopback") + let challenge = challengeData["challenge"] ?? "" + let astationId = challengeData["astation_id"] ?? "" + let proof = DeviceAuthentication.proof( + token: fixture.bootstrap.token, + challenge: challenge, + astationId: astationId, + atemId: atemId, + sessionId: "local" + ) + return .statusUpdate(status: "auth", data: [ + "method": "local_proof", + "atem_id": atemId, + "hostname": "test-mac", + "proof": proof + ]) + } + } + + private static func nonLoopbackIPv4Address() -> String? { + Host.current().addresses.first { address in + address.split(separator: ".").count == 4 && !address.hasPrefix("127.") + } + } +} + +private final class DirectServerFixture { + let directory: URL + let sessions: SessionStore + let bootstrap: LocalBootstrapStore + let server: AstationWebSocketServer + private var stopped = false + + init(host: String) throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AstationDirectTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + sessions = SessionStore(storageURL: directory.appendingPathComponent("sessions.json")) + bootstrap = try LocalBootstrapStore(directory: directory) + let hub = AstationHubManager(skipProjectLoad: true, deviceSessionStore: sessions) + server = AstationWebSocketServer( + hubManager: hub, + sessionStore: sessions, + localBootstrapStore: bootstrap + ) + try server.start(host: host, port: 0) + } + + var port: Int { + server.listeningPort ?? 0 + } + + func shutdown() { + guard !stopped else { return } + stopped = true + server.stop() + try? FileManager.default.removeItem(at: directory) + } + + deinit { + shutdown() + } +} + +private enum DirectTestFailure: Error { + case encodingFailed +} diff --git a/Tests/AstationTests/NetworkDebugLoggerTests.swift b/Tests/AstationTests/NetworkDebugLoggerTests.swift new file mode 100644 index 0000000..bd4fe18 --- /dev/null +++ b/Tests/AstationTests/NetworkDebugLoggerTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import Menubar + +final class NetworkDebugLoggerTests: XCTestCase { + func testSanitizedPayloadRedactsNestedCredentials() { + let payload = #"{"atem_id":"device-1","payload":{"data":{"session_id":"session-secret","proof":"proof-secret","token":"token-secret","hostname":"office"}}}"# + let sanitized = NetworkDebugLogger.sanitizedPayload(payload) + + XCTAssertFalse(sanitized.contains("session-secret")) + XCTAssertFalse(sanitized.contains("proof-secret")) + XCTAssertFalse(sanitized.contains("token-secret")) + XCTAssertTrue(sanitized.contains("device-1")) + XCTAssertTrue(sanitized.contains("office")) + } + + func testSanitizedPayloadLeavesNonJSONTextReadable() { + XCTAssertEqual(NetworkDebugLogger.sanitizedPayload("connection closed"), "connection closed") + } +} diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md new file mode 100644 index 0000000..f5392e0 --- /dev/null +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -0,0 +1,133 @@ +# Device Authentication v2 + +Status: implemented for direct and identity-relay WebSocket clients. Direct LAN +transport encryption and relay-owner authentication remain required before a +production rollout. + +The matching Atem implementation is in `src/websocket_client.rs` in the Atem +repository. Astation and Atem must be released together. + +## Connection matrix + +| Path | Endpoint | Interactive pairing | Internet required | +|------|----------|---------------------|-------------------| +| Same Mac | `ws://127.0.0.1:8080/ws` | No | No | +| LAN | `ws://:8080/ws` | First connection only | No | +| Remote | Identity room on the public WSS relay | First connection only | Yes | + +All three paths can be active at the same time. A connection is local only when +the kernel reports a loopback peer address. A LAN address, VPN address, forwarded +header, hostname, or claimed role never receives the loopback policy. + +## Protocol + +Astation starts every direct connection with: + +```json +{ + "type": "statusUpdate", + "data": { + "status": "auth_required", + "data": { + "astation_id": "astation-...", + "challenge": "64 lowercase hex characters", + "transport": "loopback|lan|relay", + "protocol": "2" + } + } +} +``` + +The proof is lowercase hex HMAC-SHA256 over this exact UTF-8 string: + +```text +astation-auth-v2\n\n\n\n +``` + +For loopback, `session_id` in the proof input is the literal `local`, and the +HMAC key is the same-user bootstrap token. For LAN and relay reconnects, the key +is the device session token and `session_id` is the saved session UUID. + +Astation compares proofs in constant time, binds legacy sessions to the first +`atem_id` that proves possession, and rejects reuse with a different device ID. +The relay path does not register `hello` as an authenticated client; `hello` +only causes Astation to issue a targeted challenge. + +## Local state + +Astation stores files under `~/Library/Application Support/Astation/`: + +| File | Mode | Purpose | +|------|------|---------| +| `local-bootstrap-token` | `0600` | Same-user loopback bootstrap key | +| `sessions.json` | `0600` | Device IDs, session tokens, and activity times | +| `identity.txt` | `0600` | Stable Astation routing identity | + +The directory and `~/Library/Logs/Astation/` use mode `0700`. Astation log files +use `0600`. Network logging recursively redacts tokens, session IDs, proofs, +pairing codes, and common credential fields. + +Atem stores paired device sessions in `~/.config/atem/sessions.json` with mode +`0600` under a `0700` directory. The bootstrap token is read only on macOS and +is rejected if group or other permission bits are present. + +## Operations + +Same-Mac Atem requires no configuration. It connects to the default endpoint: + +```text +ws://127.0.0.1:8080/ws +``` + +For an offline LAN Atem, configure the Mac's reachable address: + +```toml +astation_ws = "ws://192.168.1.20:8080/ws" +``` + +The first connection shows matching device/code information in Atem and an +approval dialog in Astation. Later connections use the saved session proof. +No DNS lookup, relay request, or internet service is required on this path. + +## Rollout and migration + +1. Merge both repository PRs before releasing either binary. +2. Release Astation and Atem as a coordinated version pair. +3. Existing session records remain readable, but old clients that send only a + session ID cannot authenticate against v2. +4. On `pairing required`, the updated Atem retries interactive pairing on the + same socket and saves the new token. +5. Validate loopback with Wi-Fi disabled, then validate LAN using an explicit IP. +6. Keep the current production binaries until LAN WSS and relay-owner auth land. + +## Automated coverage + +`DirectConnectionTests` starts the real NIO WebSocket server on ephemeral ports +and covers: + +- loopback authentication with no network service; +- invalid same-user proof rejection; +- application broadcasts excluded from unauthenticated sockets; +- five concurrently authenticated loopback clients; +- a pre-paired connection through a real non-loopback interface, with no relay; +- cross-language HMAC test vectors and private file modes. + +Run: + +```bash +swift test --filter DirectConnectionTests +swift test +cd relay-server && cargo test +``` + +The Atem repository adds the matching HMAC vector and bootstrap permission tests. + +## Remaining production blockers + +- Replace plaintext direct LAN WebSocket with WSS and persistent certificate + pinning or an equivalent authenticated encrypted channel. +- Authenticate the Astation owner connection before the relay creates or takes + over an identity room. +- Require the authenticated device session on Voice, LLM, Vault, and RTC owner + APIs rather than accepting a bare session identifier. +- Add device naming, session rotation, revocation, and connection history UI. diff --git a/relay-server/README.md b/relay-server/README.md index b0d8970..f27f926 100644 --- a/relay-server/README.md +++ b/relay-server/README.md @@ -62,12 +62,18 @@ Deep link authentication for Astation app. - `GET /api/sessions/:id/status` → `{status, token?}` - Poll for grant/deny - `POST /api/sessions/:id/grant {otp}` → `{token}` - User grants access (60 req/min limit) -### WebSocket Relay (Pairing) -Atem ↔ Astation message relay via pairing codes. +### WebSocket Relay (Pairing and Reconnect) +Atem <-> Astation message relay via pairing codes and persistent identity rooms. - `POST /api/pair {hostname}` → `{code}` - Create pairing room (10min expiry) - `WS /ws?role={atem|astation}&code={CODE}` - Connect and relay messages +For identity-room reconnects, the relay is the transport, not the device +authenticator. Astation sends a v2 challenge, verifies the Atem HMAC proof, and +only then returns `authenticated`. The relay binds a session to the room only +after observing that Astation response. See [`SECURITY.md`](SECURITY.md) for +current production blockers. + ### RTC Sessions Web screen sharing with up to 8 participants. @@ -130,7 +136,7 @@ RUST_LOG=debug ## Testing ```bash -cargo test # 90 tests (auth, sessions, relay, RTC, validation) +cargo test # 177 tests (auth, sessions, relay, RTC, Voice, Vault, validation) ``` diff --git a/relay-server/SECURITY.md b/relay-server/SECURITY.md index 4027124..f9e726f 100644 --- a/relay-server/SECURITY.md +++ b/relay-server/SECURITY.md @@ -1,369 +1,61 @@ -# Security Analysis: Station Relay Server - -## Current Security Status: ✅ PRODUCTION READY (with Cloudflare) - -The relay server now includes **rate limiting**, **input validation**, and **CORS policy** protection. When deployed behind Cloudflare Tunnel with HTTPS, it provides adequate security for production use. - ---- - -## ✅ Security Features Implemented - -### 1. **Rate Limiting** (Application Level) -- **OTP Validation:** 60 requests/min per IP (burst: 10) - Prevents brute force attacks -- **General API:** 600 requests/min per IP (burst: 20) - Prevents abuse -- **WebSocket:** No rate limit (long-lived connections) - -**Endpoints:** -``` -POST /api/sessions/:id/grant → 60/min (strict - brute force protection) -POST /api/sessions → 600/min (general) -POST /api/rtc-sessions → 600/min (general) -POST /api/rtc-sessions/:id/join → 600/min (general) -POST /api/pair → 600/min (general) -``` - -### 2. **Input Validation** -All user input is validated for length and format: -- **hostname:** 1-255 characters (sessions, pairing) -- **name:** 1-100 characters (RTC join) -- **channel:** 1-64 characters (RTC sessions) -- **app_id:** 1-255 characters (RTC sessions) -- **token:** 1-4096 characters (RTC sessions) - -Returns `400 Bad Request` with error details if validation fails. - -### 3. **CORS Policy** -- **Default:** `https://station.agora.build` (production) -- **Configurable:** Set `CORS_ORIGIN=*` for development (logs warning) -- **Methods:** GET, POST, DELETE, OPTIONS -- **Credentials:** Enabled (for secure cookies) - -### 4. **XSS Protection** -- HTML escaping in all rendered pages (pairing page, auth page) -- URL encoding for deep link parameters - -### 5. **Session Management** -- **Auth sessions:** 5-minute expiry, automatic cleanup -- **RTC sessions:** 4-hour expiry, automatic cleanup -- **Pairing rooms:** 10-minute expiry if unpaired -- **Participant limit:** Max 8 users per RTC session - -### 6. **Cryptographic Tokens** -- **OTP:** 8-digit random (10^8 combinations) -- **Session tokens:** 64 hex characters (256-bit entropy) -- **Session IDs:** UUID v4 (122-bit entropy) -- **Pairing codes:** 8 chars, no ambiguous characters (0/O, 1/I/L excluded) - -### 7. **In-Memory Storage** -- No persistent storage of sensitive data -- Sessions expire automatically -- No data survives server restart - ---- - -## 🔒 Deployment Architecture - -### Recommended Setup - -``` -Internet - ↓ -Reverse Proxy (Nginx/Caddy/Cloudflare) - ↓ HTTPS, Rate Limiting, DDoS Protection - ↓ -Relay Server (localhost:3000) - ↓ Application-level rate limiting, input validation -``` - -**Requirements:** -- ✅ HTTPS via reverse proxy (Nginx, Caddy, or Cloudflare) -- ✅ DDoS protection (Cloudflare recommended) -- ✅ Relay server binds to localhost only -- ✅ All traffic goes through reverse proxy - -### Environment Variables - -```bash -# CORS origin (required for production) -CORS_ORIGIN=https://station.agora.build - -# Public base URL used for generated share links (recommended) -PUBLIC_BASE_URL=https://station.agora.build - -# Port (default: 3000) -PORT=3000 - -# Log level (default: info) -RUST_LOG=info -``` - -For development: -```bash -CORS_ORIGIN=* # Allows all origins, logs warning -``` - ---- - -## 🔐 Astation Integration - -The relay server provides three services used by the Astation macOS app: - -### 1. **Auth Sessions** (Deep Link Authentication) -**Flow:** -``` -Astation → POST /api/sessions {hostname} → {id, otp} - → Opens browser: https://station.agora.build/auth?id={id}&tag={tag} - → User clicks Grant/Deny -Browser → POST /api/sessions/{id}/grant {otp} → {token} -Astation → Poll GET /api/sessions/{id}/status → {status: "granted", token} - → Uses token for authenticated operations -``` - -**Security:** -- OTP visible only to user (shown on auth page) -- Rate limited: 60 attempts/min per IP -- 5-minute session expiry - -### 2. **Pairing (Atem ↔ Astation)** -**Flow:** -``` -Atem → POST /api/pair {hostname} → {code: "ABCD-EFGH"} - → Opens browser: https://station.agora.build/pair?code=ABCD-EFGH - → User clicks "Open in Astation" -Astation → Deep link: astation://pair?code=ABCD-EFGH - → WS /ws?role=astation&code=ABCD-EFGH -Atem → WS /ws?role=atem&code=ABCD-EFGH -``` - -**Security:** -- 8-character pairing code (23^8 = 41 billion combinations) -- 10-minute expiry if unpaired -- WebSocket relay (no message inspection by server) - -### 3. **RTC Sessions** (Web Sharing) -**Flow:** -``` -Astation → Joins RTC channel (channel="room", uid=5678) - → Generates uid=0 wildcard token (AccessToken2.buildTokenRTC) - → POST /api/rtc-sessions {app_id, channel, token, host_uid} → {id, url} - → Copies URL to clipboard -Web User → Opens https://station.agora.build/session/{id} - → GET /api/rtc-sessions/{id} → {app_id, channel, host_uid} - → POST /api/rtc-sessions/{id}/join {name} → {app_id, channel, token, uid: 1000} - → Joins RTC with assigned UID -``` - -**Security:** -- uid=0 tokens allow any numeric UID (Agora feature) -- Max 8 participants enforced (atomic counter) -- 4-hour session expiry -- Names limited to 100 characters - ---- - -## ⚠️ Remaining Risks - -### 1. **OTP Brute Force** (Mitigated) -- **Risk:** 10^8 combinations for 8-digit OTP -- **Mitigation:** Rate limiting (60/min) makes brute force impractical -- **Math:** 100M combinations ÷ 60/min = 27 years per IP -- **Status:** ✅ Acceptable risk - -### 2. **Cloudflare Bypass** -- **Risk:** Direct IP access bypasses Cloudflare protection -- **Mitigation:** Use Cloudflare Tunnel (no exposed ports) -- **Status:** ✅ Resolved with Tunnel - -### 3. **Session Fixation** -- **Risk:** Attacker provides victim with known session ID -- **Mitigation:** OTP required, 5-minute expiry, UUID v4 IDs -- **Status:** ✅ Low risk - -### 4. **Resource Exhaustion** -- **Risk:** Many concurrent sessions/connections -- **Mitigation:** Rate limiting, input validation, session expiry -- **Cloudflare:** Connection limits, DDoS protection -- **Status:** ✅ Mitigated - -### 5. **WebSocket Message Injection** -- **Risk:** Malicious messages relayed between Atem/Astation -- **Mitigation:** None (pass-through relay by design) -- **Impact:** Low (endpoints trust each other after pairing) -- **Status:** ⚠️ Acceptable risk (intended behavior) - ---- - -## 📋 Pre-Production Checklist - -### Critical (Must Have) ✅ DONE -- [x] **HTTPS** - Deployed behind Cloudflare Tunnel -- [x] **Rate Limiting** - 60/min for OTP, 600/min for general API -- [x] **CORS Policy** - Whitelist station.agora.build -- [x] **Input Validation** - Max lengths enforced -- [x] **XSS Protection** - HTML escaping implemented - -### Recommended (Should Have) -- [ ] **Structured Logging** - JSON logs for security events -- [ ] **Monitoring/Alerting** - Prometheus + Grafana or similar -- [x] **Health Checks** - `/health` verifies the service and configured Vault store -- [ ] **Error Tracking** - Sentry or similar for crash reports - -### Optional (Nice to Have) -- [ ] **Admin Dashboard** - View active sessions/connections -- [ ] **Token Revocation API** - Manual session invalidation -- [ ] **Audit Logs** - Track all auth/session operations -- [ ] **2FA for Admin** - TOTP for privileged operations - ---- - -## 🚀 Deployment Steps - -### Production (Docker Compose) - -```bash -# 1. Create environment file -cd relay-server -cp .env.example .env -# Edit: -# CORS_ORIGIN=https://station.agora.build -# PUBLIC_BASE_URL=https://station.agora.build - -# 2. Deploy -docker compose up -d - -# 3. Verify -curl --fail http://localhost:3000/health -# Expected: {"status":"ok","vault_store":"postgres"} -``` - -### Staging (Coolify) - -```bash -# See STAGING-SETUP.md for complete guide - -1. Coolify → New Docker Compose service -2. GitHub: Agora-Build/Astation, path: relay-server -3. Environment: - CORS_ORIGIN=https://station-staging.agora.build - PUBLIC_BASE_URL=https://station-staging.agora.build -4. Domain: station-staging.agora.build -5. Deploy -``` - -### Configure Reverse Proxy - -**Option 1: Cloudflare (Recommended)** -- Point DNS to your server -- Enable proxying in Cloudflare -- SSL/TLS mode: Full - -**Option 2: Nginx** -```nginx -server { - listen 443 ssl; - server_name station.agora.build; - - location / { - proxy_pass http://127.0.0.1:3000; - proxy_set_header Host $host; - } -} -``` - -**Option 3: Caddy** -``` -station.agora.build { - reverse_proxy localhost:3000 -} -``` - ---- - -## 📊 Monitoring - -### Key Metrics -1. **Request Rate** - Requests/min by endpoint -2. **Error Rate** - 4xx/5xx responses -3. **Auth Success Rate** - Grant approvals vs denials -4. **Session Count** - Active auth/RTC sessions -5. **WebSocket Connections** - Active relay connections - -### Logging -```bash -# Enable structured logging -RUST_LOG=info cargo run - -# Example logs: -[INFO] Pair room created: ABCD-EFGH -[INFO] Join request for session abc-123: current participants = 3, name = Alice -[WARN] Session xyz-789 is full (8 participants) -``` - -### Alerts -- **Auth failures > 100/min** - Possible brute force attack -- **Session creation > 1000/hour** - Resource abuse -- **Error rate > 5%** - System issues -- **Memory usage > 80%** - Memory leak or high load - ---- - -## 🔧 Troubleshooting - -### CORS Errors -``` -Access to fetch at 'https://station.agora.build/api/...' from origin 'https://other-domain.com' has been blocked by CORS policy -``` - -**Fix:** Check `CORS_ORIGIN` environment variable: -```bash -# Should be: -CORS_ORIGIN=https://station.agora.build - -# Not: -CORS_ORIGIN=* # Only for development! -``` - -### Rate Limiting Errors -``` -HTTP 429 Too Many Requests -``` - -**Normal:** Client hit rate limit (60 or 600 req/min) -**Fix:** Add exponential backoff in client code - -### Input Validation Errors -``` -HTTP 400 Bad Request -{"error": "Validation error: hostname: length must be between 1 and 255"} -``` - -**Fix:** Truncate input before sending: -```swift -let hostname = String(hostName.prefix(255)) -``` - ---- - -## 🎯 Production Readiness Score - -| Category | Score | Notes | -|----------|-------|-------| -| Authentication | ✅ 9/10 | OTP with rate limiting, session expiry | -| Authorization | ⚠️ 6/10 | No auth on RTC session creation (intended) | -| Input Validation | ✅ 10/10 | All inputs validated with max lengths | -| Rate Limiting | ✅ 10/10 | Strict (60/min) + general (600/min) | -| CORS | ✅ 10/10 | Configurable, whitelisted by default | -| XSS Protection | ✅ 10/10 | HTML escaping + URL encoding | -| Data Privacy | ✅ 10/10 | In-memory only, auto-expiry | -| Monitoring | ⚠️ 5/10 | Basic logging, no structured metrics | -| Logging | ⚠️ 6/10 | tracing enabled, no audit logs | - -**Overall: ✅ 8.5/10 - PRODUCTION READY with Cloudflare** - ---- - -## 📞 Support - -- **Documentation:** See `DEPLOY.md` for deployment guide -- **Issues:** GitHub Issues (include logs + environment details) -- **Security:** Email security@agora.build for vulnerabilities +# Station Relay Security Status + +The relay server is not yet production-ready as an authorization boundary. +TLS at the reverse proxy, CORS, validation, and rate limiting are necessary but +do not replace application authentication. + +## Implemented controls + +- HTTPS/WSS is supported through the deployment reverse proxy. +- Auth grant attempts and general API requests are rate limited by client IP. +- Pairing rooms expire after 10 minutes when no Astation is connected. +- Atem messages carry a stable, sanitized `atem_id` envelope. +- The relay records a pending session claim and binds it to a room only after + Astation returns an authenticated/granted response. +- Pairing/auth pages HTML-escape user-controlled values. +- Vault authorization checks granted sessions and the Astation room binding. + +## Device authentication v2 + +The relay transports the v2 challenge and HMAC proof but does not know the device +session token. Astation is the verifier and must not register a relay Atem or +process its application messages until verification succeeds. + +```text +Atem -> relay -> Astation: hello +Atem <- relay <- Astation: auth_required {challenge, astation_id, protocol=2} +Atem -> relay -> Astation: auth {session_id, atem_id, proof} +Atem <- relay <- Astation: authenticated +``` + +The relay observes the final Astation response to populate its short-lived +session verification cache. A session ID by itself is not device authentication. + +## Production blockers + +1. `role=astation` identity-room ownership is not authenticated. A separate + per-installation relay-owner credential must be required before room creation + or replacement. +2. Voice, LLM, and RTC session endpoints are not consistently protected by an + authenticated device session. +3. Vault authorization still accepts a session identifier at the HTTP boundary; + it must be tied to the v2 device proof or a derived short-lived API token. +4. Per-Atem disconnect and replacement cleanup must be connection-generation + aware so an old socket cannot remove its replacement. +5. WebSocket connection admission and message size/rate limits need explicit + production bounds. + +Do not describe a deployment as production-ready until these items have tests +and the deployed configuration requires them. + +## Deployment baseline + +- Expose the service only through an HTTPS/WSS reverse proxy or tunnel. +- Do not publish the container port directly to the internet. +- Set a single explicit `CORS_ORIGIN`; never use `*` in production. +- Set `DATABASE_URL` for durable Vault storage. +- Keep secrets in the deployment secret store and out of URLs and logs. +- Use `RUST_LOG=info` or stricter in production. + +See `../docs/specs/2026-07-21-device-authentication-v2.md` for the coordinated +Astation/Atem protocol, rollout order, and current LAN limitation. From b462b93512c588cc0c76ce1a0adaba8fcfdf2585 Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 17:05:03 -0700 Subject: [PATCH 02/10] fix: harden device authentication boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require the explicit local proof method, fail closed if secure randomness is unavailable, redact secrets from malformed log payloads, and cover legacy binding and cross-scope token rejection. 🤖 Built with SMT --- Sources/Menubar/AstationWebSocketServer.swift | 3 +- Sources/Menubar/DeviceAuthentication.swift | 2 +- Sources/Menubar/NetworkDebugLogger.swift | 32 +++++++++++++++--- .../DeviceAuthenticationTests.swift | 27 +++++++++++++++ .../AstationTests/DirectConnectionTests.swift | 33 +++++++++++++++++++ .../NetworkDebugLoggerTests.swift | 11 +++++++ .../2026-07-21-device-authentication-v2.md | 5 +++ 7 files changed, 107 insertions(+), 6 deletions(-) diff --git a/Sources/Menubar/AstationWebSocketServer.swift b/Sources/Menubar/AstationWebSocketServer.swift index 5c01981..405b8e7 100644 --- a/Sources/Menubar/AstationWebSocketServer.swift +++ b/Sources/Menubar/AstationWebSocketServer.swift @@ -231,7 +231,8 @@ class AstationWebSocketServer { clientId: String, ws: WebSocket ) { - guard let store = localBootstrapStore, + guard authInfo["method"] == "local_proof", + let store = localBootstrapStore, let atemId = authInfo["atem_id"], let hostname = authInfo["hostname"], let proof = authInfo["proof"], diff --git a/Sources/Menubar/DeviceAuthentication.swift b/Sources/Menubar/DeviceAuthentication.swift index 9aaa3a3..161dd8e 100644 --- a/Sources/Menubar/DeviceAuthentication.swift +++ b/Sources/Menubar/DeviceAuthentication.swift @@ -67,7 +67,7 @@ enum DeviceAuthentication { fileprivate static func randomHex(byteCount: Int) -> String { var bytes = [UInt8](repeating: 0, count: byteCount) guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { - return UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + fatalError("Secure random generation failed") } return bytes.map { String(format: "%02x", $0) }.joined() } diff --git a/Sources/Menubar/NetworkDebugLogger.swift b/Sources/Menubar/NetworkDebugLogger.swift index ae05e3f..efbf43f 100644 --- a/Sources/Menubar/NetworkDebugLogger.swift +++ b/Sources/Menubar/NetworkDebugLogger.swift @@ -101,17 +101,41 @@ enum NetworkDebugLogger { options: [.sortedKeys] ), let sanitized = String(data: sanitizedData, encoding: .utf8) else { - return truncate(text) + return sanitizeUnstructuredText(text) } return truncate(sanitized) } private static let sensitiveKeys: Set = [ - "access_token", "api_key", "app_certificate", "authorization", - "cookie", "credential", "encryption_key", "pairing_code", "password", - "proof", "refresh_token", "secret", "session", "session_id", "token" + "access_token", "api_key", "app_certificate", "auth_token", "authorization", + "bearer", "bootstrap_token", "cookie", "credential", "encryption_key", "otp", + "pairing_code", "password", "proof", "refresh_token", "secret", "session", + "session_id", "session_token", "token" ] + private static func sanitizeUnstructuredText(_ text: String) -> String { + let replacements = [ + (#"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"#, "Bearer "), + ( + #"(?i)\b(access[_-]?token|api[_-]?key|auth[_-]?token|bootstrap[_-]?token|otp|pairing[_-]?code|password|proof|refresh[_-]?token|secret|session[_-]?id|session[_-]?token|token)\b\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;&]+)"#, + "$1=" + ) + ] + let sanitized = replacements.reduce(text) { value, replacement in + guard let expression = try? NSRegularExpression( + pattern: replacement.0, + options: [] + ) else { return value } + return expression.stringByReplacingMatches( + in: value, + options: [], + range: NSRange(value.startIndex.. Any { if let dictionary = value as? [String: Any] { return dictionary.reduce(into: [String: Any]()) { result, entry in diff --git a/Tests/AstationTests/DeviceAuthenticationTests.swift b/Tests/AstationTests/DeviceAuthenticationTests.swift index 242ca4a..a9047ba 100644 --- a/Tests/AstationTests/DeviceAuthenticationTests.swift +++ b/Tests/AstationTests/DeviceAuthenticationTests.swift @@ -85,4 +85,31 @@ final class DeviceAuthenticationTests: XCTestCase { astationId: "astation-home" )) } + + func testLegacySessionBindsToFirstDeviceWithValidProof() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AstationLegacySessionTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = SessionStore(storageURL: directory.appendingPathComponent("sessions.json")) + let session = store.create(hostname: "legacy") + let proof = DeviceAuthentication.proof( + token: session.token, + challenge: "nonce", + astationId: "astation-home", + atemId: "atem-first", + sessionId: session.id + ) + + let authenticated = store.authenticate( + sessionId: session.id, + atemId: "atem-first", + challenge: "nonce", + proof: proof, + astationId: "astation-home" + ) + XCTAssertEqual(authenticated?.atemId, "atem-first") + XCTAssertEqual(store.get(sessionId: session.id)?.atemId, "atem-first") + } } diff --git a/Tests/AstationTests/DirectConnectionTests.swift b/Tests/AstationTests/DirectConnectionTests.swift index 47787ab..11253a2 100644 --- a/Tests/AstationTests/DirectConnectionTests.swift +++ b/Tests/AstationTests/DirectConnectionTests.swift @@ -45,6 +45,39 @@ final class DirectConnectionTests: XCTestCase { XCTAssertEqual(result, "error:Local authentication failed") } + func testLANSessionTokenCannotAuthenticateAsLoopback() throws { + let fixture = try DirectServerFixture(host: "127.0.0.1") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + + let atemId = "cross-scope-atem" + let session = fixture.sessions.create(hostname: "office", atemId: atemId) + let future = connect( + host: "127.0.0.1", + fixture: fixture, + group: group, + authMessage: { challengeData in + let proof = DeviceAuthentication.proof( + token: session.token, + challenge: challengeData["challenge"] ?? "", + astationId: challengeData["astation_id"] ?? "", + atemId: atemId, + sessionId: "local" + ) + return .statusUpdate(status: "auth", data: [ + "method": "local_proof", + "atem_id": atemId, + "hostname": "office", + "proof": proof + ]) + } + ) + + let (result, _) = try future.wait() + XCTAssertEqual(result, "error:Local authentication failed") + } + func testFiveLoopbackClientsAuthenticateConcurrently() throws { let fixture = try DirectServerFixture(host: "127.0.0.1") defer { fixture.shutdown() } diff --git a/Tests/AstationTests/NetworkDebugLoggerTests.swift b/Tests/AstationTests/NetworkDebugLoggerTests.swift index bd4fe18..4bc0015 100644 --- a/Tests/AstationTests/NetworkDebugLoggerTests.swift +++ b/Tests/AstationTests/NetworkDebugLoggerTests.swift @@ -16,4 +16,15 @@ final class NetworkDebugLoggerTests: XCTestCase { func testSanitizedPayloadLeavesNonJSONTextReadable() { XCTAssertEqual(NetworkDebugLogger.sanitizedPayload("connection closed"), "connection closed") } + + func testSanitizedPayloadRedactsUnstructuredSecrets() { + let sanitized = NetworkDebugLogger.sanitizedPayload( + "request failed token=secret-value Authorization: Bearer header.payload.signature" + ) + + XCTAssertFalse(sanitized.contains("secret-value")) + XCTAssertFalse(sanitized.contains("header.payload.signature")) + XCTAssertTrue(sanitized.contains("token=")) + XCTAssertTrue(sanitized.contains("Bearer ")) + } } diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index f5392e0..9e906bf 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -19,6 +19,10 @@ All three paths can be active at the same time. A connection is local only when the kernel reports a loopback peer address. A LAN address, VPN address, forwarded header, hostname, or claimed role never receives the loopback policy. +The same-Mac policy relies on the bootstrap file and its parent directory being +owner-only (`0600`/`0700`); it proves access as the Astation OS account rather +than inspecting the connecting process UID. + ## Protocol Astation starts every direct connection with: @@ -107,6 +111,7 @@ and covers: - loopback authentication with no network service; - invalid same-user proof rejection; +- LAN session credentials rejected on the loopback scope; - application broadcasts excluded from unauthenticated sockets; - five concurrently authenticated loopback clients; - a pre-paired connection through a real non-loopback interface, with no relay; From 76f75c66cf2f613a28cee64fb15abda1c3427702 Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 17:11:58 -0700 Subject: [PATCH 03/10] fix: route authenticated relay broadcasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliver hub broadcasts across both authenticated transports, normalize camelCase secret keys during log redaction, cover truncated payloads, and log legacy session claims. 🤖 Built with SMT --- Sources/Menubar/AstationApp.swift | 3 ++- Sources/Menubar/AstationHubManager.swift | 6 ++++++ Sources/Menubar/NetworkDebugLogger.swift | 18 +++++++++++------- Sources/Menubar/SessionStore.swift | 4 ++++ .../NetworkDebugLoggerTests.swift | 12 +++++++++++- .../2026-07-21-device-authentication-v2.md | 2 ++ 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Sources/Menubar/AstationApp.swift b/Sources/Menubar/AstationApp.swift index 2b788e8..8bc8636 100644 --- a/Sources/Menubar/AstationApp.swift +++ b/Sources/Menubar/AstationApp.swift @@ -68,8 +68,9 @@ class AstationApp: NSObject, NSApplicationDelegate { } // Wire broadcast handler so hubManager can broadcast to all connected Atems - hubManager.broadcastHandler = { [weak webSocketServer] message in + hubManager.broadcastHandler = { [weak webSocketServer, weak hubManager] message in webSocketServer?.broadcastMessage(message) + hubManager?.broadcastToAuthenticatedIdentityRelayClients(message) } // Wire send handler so hubManager can send to a specific Atem by client ID diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index 4a67e06..9caafe3 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -1227,6 +1227,12 @@ class AstationHubManager: ObservableObject { } } + func broadcastToAuthenticatedIdentityRelayClients(_ message: AstationMessage) { + for clientId in authenticatedIdentityRelayClients { + sendHandler?(message, clientId) + } + } + private func handleIdentityRelayAuthentication(_ msg: AstationMessage, clientId: String) { guard case .statusUpdate(let status, let data) = msg, status == "auth", diff --git a/Sources/Menubar/NetworkDebugLogger.swift b/Sources/Menubar/NetworkDebugLogger.swift index efbf43f..543f477 100644 --- a/Sources/Menubar/NetworkDebugLogger.swift +++ b/Sources/Menubar/NetworkDebugLogger.swift @@ -107,17 +107,17 @@ enum NetworkDebugLogger { } private static let sensitiveKeys: Set = [ - "access_token", "api_key", "app_certificate", "auth_token", "authorization", - "bearer", "bootstrap_token", "cookie", "credential", "encryption_key", "otp", - "pairing_code", "password", "proof", "refresh_token", "secret", "session", - "session_id", "session_token", "token" + "accesstoken", "apikey", "appcertificate", "authtoken", "authorization", + "bearer", "bootstraptoken", "cookie", "credential", "encryptionkey", "otp", + "pairingcode", "password", "proof", "refreshtoken", "secret", "session", + "sessionid", "sessiontoken", "token" ] private static func sanitizeUnstructuredText(_ text: String) -> String { let replacements = [ (#"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"#, "Bearer "), ( - #"(?i)\b(access[_-]?token|api[_-]?key|auth[_-]?token|bootstrap[_-]?token|otp|pairing[_-]?code|password|proof|refresh[_-]?token|secret|session[_-]?id|session[_-]?token|token)\b\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;&]+)"#, + #"(?i)\b(access[_-]?token|api[_-]?key|auth[_-]?token|bootstrap[_-]?token|otp|pairing[_-]?code|password|proof|refresh[_-]?token|secret|session[_-]?id|session[_-]?token|token)\b[\"']?\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;&]+)"#, "$1=" ) ] @@ -139,7 +139,7 @@ enum NetworkDebugLogger { private static func sanitizeJSONObject(_ value: Any) -> Any { if let dictionary = value as? [String: Any] { return dictionary.reduce(into: [String: Any]()) { result, entry in - let normalizedKey = entry.key.lowercased().replacingOccurrences(of: "-", with: "_") + let normalizedKey = normalizedSensitiveKey(entry.key) result[entry.key] = sensitiveKeys.contains(normalizedKey) ? "" : sanitizeJSONObject(entry.value) @@ -158,10 +158,14 @@ enum NetworkDebugLogger { return url.absoluteString } components.queryItems = items.map { item in - let normalizedName = item.name.lowercased().replacingOccurrences(of: "-", with: "_") + let normalizedName = normalizedSensitiveKey(item.name) guard sensitiveKeys.contains(normalizedName) else { return item } return URLQueryItem(name: item.name, value: "") } return components.string ?? url.absoluteString } + + private static func normalizedSensitiveKey(_ key: String) -> String { + key.lowercased().filter { $0.isLetter || $0.isNumber } + } } diff --git a/Sources/Menubar/SessionStore.swift b/Sources/Menubar/SessionStore.swift index 2f39b9e..5de2cec 100644 --- a/Sources/Menubar/SessionStore.swift +++ b/Sources/Menubar/SessionStore.swift @@ -118,6 +118,7 @@ class SessionStore { queue.sync(flags: .barrier) { guard var session = sessions[sessionId], session.isValid else { return nil } guard session.atemId == nil || session.atemId == atemId else { return nil } + let bindsLegacySession = session.atemId == nil guard DeviceAuthentication.verify( proof: proof, token: session.token, @@ -131,6 +132,9 @@ class SessionStore { session.lastActivity = Date() sessions[sessionId] = session saveToDisk() + if bindsLegacySession { + Log.info("Bound legacy session \(sessionId.prefix(8)) to Atem \(atemId)") + } return session } } diff --git a/Tests/AstationTests/NetworkDebugLoggerTests.swift b/Tests/AstationTests/NetworkDebugLoggerTests.swift index 4bc0015..4a7dbab 100644 --- a/Tests/AstationTests/NetworkDebugLoggerTests.swift +++ b/Tests/AstationTests/NetworkDebugLoggerTests.swift @@ -3,10 +3,11 @@ import XCTest final class NetworkDebugLoggerTests: XCTestCase { func testSanitizedPayloadRedactsNestedCredentials() { - let payload = #"{"atem_id":"device-1","payload":{"data":{"session_id":"session-secret","proof":"proof-secret","token":"token-secret","hostname":"office"}}}"# + let payload = #"{"atem_id":"device-1","payload":{"data":{"session_id":"session-secret","sessionToken":"camel-secret","proof":"proof-secret","token":"token-secret","hostname":"office"}}}"# let sanitized = NetworkDebugLogger.sanitizedPayload(payload) XCTAssertFalse(sanitized.contains("session-secret")) + XCTAssertFalse(sanitized.contains("camel-secret")) XCTAssertFalse(sanitized.contains("proof-secret")) XCTAssertFalse(sanitized.contains("token-secret")) XCTAssertTrue(sanitized.contains("device-1")) @@ -27,4 +28,13 @@ final class NetworkDebugLoggerTests: XCTestCase { XCTAssertTrue(sanitized.contains("token=")) XCTAssertTrue(sanitized.contains("Bearer ")) } + + func testSanitizedPayloadRedactsSecretInTruncatedJSON() { + let sanitized = NetworkDebugLogger.sanitizedPayload( + #"{"data":{"sessionToken":"truncated-secret""# + ) + + XCTAssertFalse(sanitized.contains("truncated-secret")) + XCTAssertTrue(sanitized.contains("sessionToken=")) + } } diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index 9e906bf..915765f 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -56,6 +56,8 @@ Astation compares proofs in constant time, binds legacy sessions to the first `atem_id` that proves possession, and rejects reuse with a different device ID. The relay path does not register `hello` as an authenticated client; `hello` only causes Astation to issue a targeted challenge. +Application broadcasts are delivered to authenticated direct and identity-relay +clients, while pending clients are excluded. ## Local state From 1831f975a8c2a41e5ce2603cbe5f270f35c478ad Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 17:18:50 -0700 Subject: [PATCH 04/10] fix: confine authentication transport state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run direct socket access on the NIO event loop, serialize relay broadcasts and pairing UI on main, and sanitize untrusted device labels before display or persistence. 🤖 Built with SMT --- Sources/Menubar/AstationApp.swift | 6 +++-- Sources/Menubar/AstationHubManager.swift | 6 +++-- Sources/Menubar/AstationWebSocketServer.swift | 26 +++++++++++++++---- Sources/Menubar/DeviceAuthentication.swift | 10 +++++++ .../DeviceAuthenticationTests.swift | 10 +++++++ .../2026-07-21-device-authentication-v2.md | 2 ++ 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/Sources/Menubar/AstationApp.swift b/Sources/Menubar/AstationApp.swift index 8bc8636..19daa7e 100644 --- a/Sources/Menubar/AstationApp.swift +++ b/Sources/Menubar/AstationApp.swift @@ -69,8 +69,10 @@ class AstationApp: NSObject, NSApplicationDelegate { // Wire broadcast handler so hubManager can broadcast to all connected Atems hubManager.broadcastHandler = { [weak webSocketServer, weak hubManager] message in - webSocketServer?.broadcastMessage(message) - hubManager?.broadcastToAuthenticatedIdentityRelayClients(message) + DispatchQueue.main.async { + webSocketServer?.broadcastMessage(message) + hubManager?.broadcastToAuthenticatedIdentityRelayClients(message) + } } // Wire send handler so hubManager can send to a specific Atem by client ID diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index 9caafe3..62bdae4 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -1202,7 +1202,7 @@ class AstationHubManager: ObservableObject { private func handleIdentityRelayMessage(_ msg: AstationMessage, task: URLSessionWebSocketTask, clientId: String) { if case .statusUpdate(let status, let data) = msg, status == "hello" { - let hostname = data["hostname"] ?? "unknown" + let hostname = DeviceAuthentication.deviceLabel(data["hostname"] ?? "unknown") let challenge = DeviceAuthentication.makeChallenge() identityRelayAuthChallenges[clientId] = challenge authenticatedIdentityRelayClients.remove(clientId) @@ -1269,12 +1269,14 @@ class AstationHubManager: ObservableObject { } guard let pairingCode = data["pairing_code"], - let hostname = data["hostname"], + let rawHostname = data["hostname"], let atemId = data["atem_id"] else { sendHandler?(.error(message: "Invalid relay authentication credentials"), clientId) return } + let hostname = DeviceAuthentication.deviceLabel(rawHostname) + dispatchPrecondition(condition: .onQueue(.main)) let alert = NSAlert() alert.messageText = "Remote Atem Pairing Request" alert.informativeText = "Device: \(hostname)\nCode: \(pairingCode)\n\nAllow this Atem to connect through the relay?" diff --git a/Sources/Menubar/AstationWebSocketServer.swift b/Sources/Menubar/AstationWebSocketServer.swift index 405b8e7..71a45ff 100644 --- a/Sources/Menubar/AstationWebSocketServer.swift +++ b/Sources/Menubar/AstationWebSocketServer.swift @@ -206,11 +206,11 @@ class AstationWebSocketServer { } if let pairingCode = authInfo["pairing_code"], - let hostname = authInfo["hostname"], + let rawHostname = authInfo["hostname"], let atemId = authInfo["atem_id"] { showPairingDialog( code: pairingCode, - hostname: hostname, + hostname: DeviceAuthentication.deviceLabel(rawHostname), atemId: atemId, clientId: clientId, ws: ws @@ -234,7 +234,7 @@ class AstationWebSocketServer { guard authInfo["method"] == "local_proof", let store = localBootstrapStore, let atemId = authInfo["atem_id"], - let hostname = authInfo["hostname"], + let rawHostname = authInfo["hostname"], let proof = authInfo["proof"], DeviceAuthentication.verify( proof: proof, @@ -250,6 +250,7 @@ class AstationWebSocketServer { return } + let hostname = DeviceAuthentication.deviceLabel(rawHostname) let session = sessionStore.createOrRefreshLocal(hostname: hostname, atemId: atemId) authenticatedClients.insert(clientId) pendingAuthentication.removeValue(forKey: clientId) @@ -379,10 +380,20 @@ class AstationWebSocketServer { } func sendMessageToClient(_ message: AstationMessage, clientId: String) { - sendMessage(message, to: clientId) + guard let eventLoopGroup else { return } + eventLoopGroup.next().execute { + self.sendMessage(message, to: clientId) + } } func broadcastMessage(_ message: AstationMessage) { + guard let eventLoopGroup else { return } + eventLoopGroup.next().execute { + self.broadcastMessageOnEventLoop(message) + } + } + + private func broadcastMessageOnEventLoop(_ message: AstationMessage) { guard let data = try? JSONEncoder().encode(message), let text = String(data: data, encoding: .utf8) else { Log.error("Failed to encode broadcast message") @@ -397,7 +408,12 @@ class AstationWebSocketServer { } func getConnectedClientsCount() -> Int { - return connectedClients.count + guard let eventLoopGroup else { return 0 } + let eventLoop = eventLoopGroup.next() + if eventLoop.inEventLoop { + return connectedClients.count + } + return (try? eventLoop.submit { self.connectedClients.count }.wait()) ?? 0 } var listeningPort: Int? { diff --git a/Sources/Menubar/DeviceAuthentication.swift b/Sources/Menubar/DeviceAuthentication.swift index 161dd8e..bc3c3ad 100644 --- a/Sources/Menubar/DeviceAuthentication.swift +++ b/Sources/Menubar/DeviceAuthentication.swift @@ -8,6 +8,16 @@ enum DeviceAuthentication { randomHex(byteCount: 32) } + static func deviceLabel(_ value: String) -> String { + let cleaned = value.unicodeScalars.lazy + .filter { !CharacterSet.controlCharacters.contains($0) } + .prefix(255) + .map(String.init) + .joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? "unknown" : cleaned + } + static func proof( token: String, challenge: String, diff --git a/Tests/AstationTests/DeviceAuthenticationTests.swift b/Tests/AstationTests/DeviceAuthenticationTests.swift index a9047ba..b7a6c5b 100644 --- a/Tests/AstationTests/DeviceAuthenticationTests.swift +++ b/Tests/AstationTests/DeviceAuthenticationTests.swift @@ -3,6 +3,16 @@ import XCTest @testable import Menubar final class DeviceAuthenticationTests: XCTestCase { + func testDeviceLabelRemovesControlsAndBoundsLength() { + let label = DeviceAuthentication.deviceLabel( + "office\n\u{0000}" + String(repeating: "x", count: 300) + ) + + XCTAssertFalse(label.contains("\n")) + XCTAssertEqual(label.count, 255) + XCTAssertEqual(DeviceAuthentication.deviceLabel("\n\t"), "unknown") + } + func testProofMatchesProtocolVector() { let proof = DeviceAuthentication.proof( token: "token-abc", diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index 915765f..ca65d67 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -58,6 +58,8 @@ The relay path does not register `hello` as an authenticated client; `hello` only causes Astation to issue a targeted challenge. Application broadcasts are delivered to authenticated direct and identity-relay clients, while pending clients are excluded. +Direct connection state is confined to the NIO event loop; relay authentication +state and pairing UI are confined to the main queue. ## Local state From 2b01d169617fe1d8a1c6b8762b6e2310345fb1ce Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 22:29:39 -0700 Subject: [PATCH 05/10] fix: harden authentication review boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make direct and relay state confinement explicit, keep legacy session verification behind authentication, and reject oversized or malformed authentication fields before proof, persistence, or UI processing. 🤖 Built with SMT --- Sources/Menubar/AstationHubManager.swift | 10 +++- Sources/Menubar/AstationWebSocketServer.swift | 34 ++++++++++---- Sources/Menubar/DeviceAuthentication.swift | 46 +++++++++++++++++++ Sources/Menubar/SessionStore.swift | 6 ++- .../DeviceAuthenticationTests.swift | 15 ++++++ .../AstationTests/DirectConnectionTests.swift | 22 +++++++++ .../2026-07-21-device-authentication-v2.md | 9 +++- 7 files changed, 131 insertions(+), 11 deletions(-) diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index 62bdae4..d8ad5ce 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -1201,6 +1201,7 @@ class AstationHubManager: ObservableObject { } private func handleIdentityRelayMessage(_ msg: AstationMessage, task: URLSessionWebSocketTask, clientId: String) { + dispatchPrecondition(condition: .onQueue(.main)) if case .statusUpdate(let status, let data) = msg, status == "hello" { let hostname = DeviceAuthentication.deviceLabel(data["hostname"] ?? "unknown") let challenge = DeviceAuthentication.makeChallenge() @@ -1228,12 +1229,14 @@ class AstationHubManager: ObservableObject { } func broadcastToAuthenticatedIdentityRelayClients(_ message: AstationMessage) { + dispatchPrecondition(condition: .onQueue(.main)) for clientId in authenticatedIdentityRelayClients { sendHandler?(message, clientId) } } private func handleIdentityRelayAuthentication(_ msg: AstationMessage, clientId: String) { + dispatchPrecondition(condition: .onQueue(.main)) guard case .statusUpdate(let status, let data) = msg, status == "auth", let challenge = identityRelayAuthChallenges[clientId] else { @@ -1244,6 +1247,8 @@ class AstationHubManager: ObservableObject { if let sessionId = data["session_id"], let atemId = data["atem_id"], let proof = data["proof"], + DeviceAuthentication.isValidSessionId(sessionId), + DeviceAuthentication.isValidAtemId(atemId), let session = deviceSessionStore.authenticate( sessionId: sessionId, atemId: atemId, @@ -1270,7 +1275,9 @@ class AstationHubManager: ObservableObject { guard let pairingCode = data["pairing_code"], let rawHostname = data["hostname"], - let atemId = data["atem_id"] else { + let atemId = data["atem_id"], + DeviceAuthentication.isValidPairingCode(pairingCode), + DeviceAuthentication.isValidAtemId(atemId) else { sendHandler?(.error(message: "Invalid relay authentication credentials"), clientId) return } @@ -1307,6 +1314,7 @@ class AstationHubManager: ObservableObject { hostname: String, response: AstationMessage ) { + dispatchPrecondition(condition: .onQueue(.main)) identityRelayAuthChallenges.removeValue(forKey: clientId) authenticatedIdentityRelayClients.insert(clientId) sendHandler?(response, clientId) diff --git a/Sources/Menubar/AstationWebSocketServer.swift b/Sources/Menubar/AstationWebSocketServer.swift index 71a45ff..6c22ca3 100644 --- a/Sources/Menubar/AstationWebSocketServer.swift +++ b/Sources/Menubar/AstationWebSocketServer.swift @@ -7,6 +7,7 @@ import NIOWebSocket class AstationWebSocketServer { private var eventLoopGroup: EventLoopGroup! + private var stateEventLoop: EventLoop? private var channel: Channel? private let hubManager: AstationHubManager private var connectedClients: [String: WebSocket] = [:] @@ -30,6 +31,7 @@ class AstationWebSocketServer { func start(host: String, port: Int) throws { eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + stateEventLoop = eventLoopGroup.next() let upgrader = NIOWebSocketServerUpgrader( shouldUpgrade: { channel, _ in @@ -75,6 +77,7 @@ class AstationWebSocketServer { } private func handleWebSocketConnection(_ ws: WebSocket, scope: DirectConnectionScope) { + preconditionOnStateEventLoop() let clientId = UUID().uuidString connectedClients[clientId] = ws let challenge = DeviceAuthentication.makeChallenge() @@ -134,6 +137,7 @@ class AstationWebSocketServer { } if case .statusUpdate(let status, let messageData) = message, status == "session_verify_request" { + // This legacy control message is deliberately protected by v2 authentication. handleSessionVerifyRequest(messageData, from: clientId) return } @@ -207,7 +211,9 @@ class AstationWebSocketServer { if let pairingCode = authInfo["pairing_code"], let rawHostname = authInfo["hostname"], - let atemId = authInfo["atem_id"] { + let atemId = authInfo["atem_id"], + DeviceAuthentication.isValidPairingCode(pairingCode), + DeviceAuthentication.isValidAtemId(atemId) { showPairingDialog( code: pairingCode, hostname: DeviceAuthentication.deviceLabel(rawHostname), @@ -281,7 +287,9 @@ class AstationWebSocketServer { private func handleSessionVerifyRequest(_ data: [String: String], from clientId: String) { guard let sessionId = data["session_id"], - let requestId = data["request_id"] else { + let requestId = data["request_id"], + DeviceAuthentication.isValidSessionId(sessionId), + DeviceAuthentication.isValidRequestId(requestId) else { Log.warn("⚠️ Session verify request missing required fields") return } @@ -380,15 +388,13 @@ class AstationWebSocketServer { } func sendMessageToClient(_ message: AstationMessage, clientId: String) { - guard let eventLoopGroup else { return } - eventLoopGroup.next().execute { + executeOnStateEventLoop { self.sendMessage(message, to: clientId) } } func broadcastMessage(_ message: AstationMessage) { - guard let eventLoopGroup else { return } - eventLoopGroup.next().execute { + executeOnStateEventLoop { self.broadcastMessageOnEventLoop(message) } } @@ -408,14 +414,26 @@ class AstationWebSocketServer { } func getConnectedClientsCount() -> Int { - guard let eventLoopGroup else { return 0 } - let eventLoop = eventLoopGroup.next() + guard let eventLoop = stateEventLoop else { return 0 } if eventLoop.inEventLoop { return connectedClients.count } return (try? eventLoop.submit { self.connectedClients.count }.wait()) ?? 0 } + private func executeOnStateEventLoop(_ operation: @escaping () -> Void) { + guard let eventLoop = stateEventLoop else { return } + if eventLoop.inEventLoop { + operation() + } else { + eventLoop.execute(operation) + } + } + + private func preconditionOnStateEventLoop() { + precondition(stateEventLoop?.inEventLoop == true, "Direct connection state left its NIO event loop") + } + var listeningPort: Int? { channel?.localAddress?.port } diff --git a/Sources/Menubar/DeviceAuthentication.swift b/Sources/Menubar/DeviceAuthentication.swift index bc3c3ad..c0a7344 100644 --- a/Sources/Menubar/DeviceAuthentication.swift +++ b/Sources/Menubar/DeviceAuthentication.swift @@ -3,6 +3,10 @@ import Foundation enum DeviceAuthentication { static let protocolVersion = "2" + static let maxAtemIdBytes = 255 + static let maxSessionIdBytes = 128 + static let maxRequestIdBytes = 128 + static let maxPairingCodeBytes = 32 static func makeChallenge() -> String { randomHex(byteCount: 32) @@ -44,6 +48,11 @@ enum DeviceAuthentication { atemId: String, sessionId: String ) -> Bool { + guard isValidAtemId(atemId), + isValidSessionId(sessionId), + isValidProof(candidate) else { + return false + } let expected = proof( token: token, challenge: challenge, @@ -54,6 +63,22 @@ enum DeviceAuthentication { return constantTimeEqual(candidate.lowercased(), expected) } + static func isValidAtemId(_ value: String) -> Bool { + isBoundedText(value, maxBytes: maxAtemIdBytes) + } + + static func isValidSessionId(_ value: String) -> Bool { + isBoundedText(value, maxBytes: maxSessionIdBytes) + } + + static func isValidRequestId(_ value: String) -> Bool { + isBoundedText(value, maxBytes: maxRequestIdBytes) + } + + static func isValidPairingCode(_ value: String) -> Bool { + isBoundedText(value, maxBytes: maxPairingCodeBytes) + } + private static func canonicalMessage( challenge: String, astationId: String, @@ -74,6 +99,27 @@ enum DeviceAuthentication { return difference == 0 } + private static func isBoundedText(_ value: String, maxBytes: Int) -> Bool { + guard !value.isEmpty, value.utf8.count <= maxBytes else { return false } + return !value.unicodeScalars.contains { + CharacterSet.controlCharacters.contains($0) + } + } + + private static func isValidProof(_ value: String) -> Bool { + var count = 0 + for byte in value.utf8 { + count += 1 + guard count <= 64, + (48...57).contains(byte) || + (65...70).contains(byte) || + (97...102).contains(byte) else { + return false + } + } + return count == 64 + } + fileprivate static func randomHex(byteCount: Int) -> String { var bytes = [UInt8](repeating: 0, count: byteCount) guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { diff --git a/Sources/Menubar/SessionStore.swift b/Sources/Menubar/SessionStore.swift index 5de2cec..5739b9e 100644 --- a/Sources/Menubar/SessionStore.swift +++ b/Sources/Menubar/SessionStore.swift @@ -115,7 +115,11 @@ class SessionStore { proof: String, astationId: String ) -> SessionInfo? { - queue.sync(flags: .barrier) { + guard DeviceAuthentication.isValidSessionId(sessionId), + DeviceAuthentication.isValidAtemId(atemId) else { + return nil + } + return queue.sync(flags: .barrier) { guard var session = sessions[sessionId], session.isValid else { return nil } guard session.atemId == nil || session.atemId == atemId else { return nil } let bindsLegacySession = session.atemId == nil diff --git a/Tests/AstationTests/DeviceAuthenticationTests.swift b/Tests/AstationTests/DeviceAuthenticationTests.swift index b7a6c5b..c1b1f8a 100644 --- a/Tests/AstationTests/DeviceAuthenticationTests.swift +++ b/Tests/AstationTests/DeviceAuthenticationTests.swift @@ -41,6 +41,21 @@ final class DeviceAuthenticationTests: XCTestCase { )) } + func testRejectsOversizedOrMalformedAuthenticationFields() { + XCTAssertFalse(DeviceAuthentication.isValidAtemId(String(repeating: "a", count: 256))) + XCTAssertFalse(DeviceAuthentication.isValidSessionId("session\nother")) + XCTAssertFalse(DeviceAuthentication.isValidRequestId("")) + XCTAssertFalse(DeviceAuthentication.isValidPairingCode(String(repeating: "1", count: 33))) + XCTAssertFalse(DeviceAuthentication.verify( + proof: String(repeating: "z", count: 64), + token: "token-abc", + challenge: "challenge-123", + astationId: "astation-home", + atemId: "atem-office", + sessionId: "session-456" + )) + } + func testBootstrapSecretIsStableAndPrivate() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("AstationBootstrapTests-\(UUID().uuidString)") diff --git a/Tests/AstationTests/DirectConnectionTests.swift b/Tests/AstationTests/DirectConnectionTests.swift index 11253a2..b29f193 100644 --- a/Tests/AstationTests/DirectConnectionTests.swift +++ b/Tests/AstationTests/DirectConnectionTests.swift @@ -45,6 +45,28 @@ final class DirectConnectionTests: XCTestCase { XCTAssertEqual(result, "error:Local authentication failed") } + func testUnauthenticatedSessionVerificationIsRejected() throws { + let fixture = try DirectServerFixture(host: "127.0.0.1") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + + let future = connect( + host: "127.0.0.1", + fixture: fixture, + group: group, + authMessage: { _ in + .statusUpdate(status: "session_verify_request", data: [ + "session_id": UUID().uuidString, + "request_id": UUID().uuidString + ]) + } + ) + + let (result, _) = try future.wait() + XCTAssertEqual(result, "error:Authentication required") + } + func testLANSessionTokenCannotAuthenticateAsLoopback() throws { let fixture = try DirectServerFixture(host: "127.0.0.1") defer { fixture.shutdown() } diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index ca65d67..0ad9fc5 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -61,6 +61,11 @@ clients, while pending clients are excluded. Direct connection state is confined to the NIO event loop; relay authentication state and pairing UI are confined to the main queue. +Authentication input is bounded before proof or UI processing: `atem_id` is at +most 255 UTF-8 bytes, session and request IDs are at most 128 bytes, pairing +codes are at most 32 bytes, and HMAC proofs are exactly 64 hexadecimal bytes. +Empty values and control characters are rejected. + ## Local state Astation stores files under `~/Library/Application Support/Astation/`: @@ -117,9 +122,11 @@ and covers: - invalid same-user proof rejection; - LAN session credentials rejected on the loopback scope; - application broadcasts excluded from unauthenticated sockets; +- session-verification control messages rejected before device authentication; - five concurrently authenticated loopback clients; - a pre-paired connection through a real non-loopback interface, with no relay; -- cross-language HMAC test vectors and private file modes. +- cross-language HMAC test vectors, bounded authentication input, and private + file modes. Run: From d88cd3afdbcf9d4f23667188eed99c18913e4dab Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 22:38:27 -0700 Subject: [PATCH 06/10] fix: rotate insecure bootstrap credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate bootstrap ownership, type, and permissions before reuse; replace insecure or symlinked tokens; and bound pending relay authentication challenges by count and lifetime. 🤖 Built with SMT --- Sources/Menubar/AstationHubManager.swift | 13 ++- Sources/Menubar/DeviceAuthentication.swift | 107 +++++++++++++++++- .../DeviceAuthenticationTests.swift | 55 +++++++++ .../2026-07-21-device-authentication-v2.md | 3 + 4 files changed, 168 insertions(+), 10 deletions(-) diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index d8ad5ce..857624d 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -42,7 +42,7 @@ class AstationHubManager: ObservableObject { /// NWPathMonitor for the identity relay — fires when network becomes available, /// enabling immediate reconnect without polling. Created once and reused. private var identityRelayPathMonitor: NWPathMonitor? - private var identityRelayAuthChallenges: [String: String] = [:] + private var identityRelayAuthChallenges = RelayAuthenticationChallengeStore() private var authenticatedIdentityRelayClients: Set = [] /// Station relay URL. Priority: test override > ASTATION_RELAY_URL env var > UserDefaults > default. @@ -1163,6 +1163,7 @@ class AstationHubManager: ObservableObject { if let data = text.data(using: .utf8), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let atemId = envelope["atem_id"] as? String, + DeviceAuthentication.isValidAtemId(atemId), let payloadObj = envelope["payload"], let payloadData = try? JSONSerialization.data(withJSONObject: payloadObj), let msg = try? JSONDecoder().decode(AstationMessage.self, from: payloadData) { @@ -1205,7 +1206,11 @@ class AstationHubManager: ObservableObject { if case .statusUpdate(let status, let data) = msg, status == "hello" { let hostname = DeviceAuthentication.deviceLabel(data["hostname"] ?? "unknown") let challenge = DeviceAuthentication.makeChallenge() - identityRelayAuthChallenges[clientId] = challenge + guard identityRelayAuthChallenges.issue(clientId: clientId, challenge: challenge) else { + sendHandler?(.error(message: "Too many pending relay authentication requests"), clientId) + Log.warn("[AstationHub] Relay authentication challenge limit reached") + return + } authenticatedIdentityRelayClients.remove(clientId) sendHandler?(.statusUpdate(status: "auth_required", data: [ "astation_id": AstationIdentity.shared.id, @@ -1239,7 +1244,7 @@ class AstationHubManager: ObservableObject { dispatchPrecondition(condition: .onQueue(.main)) guard case .statusUpdate(let status, let data) = msg, status == "auth", - let challenge = identityRelayAuthChallenges[clientId] else { + let challenge = identityRelayAuthChallenges.challenge(for: clientId) else { Log.warn("[AstationHub] Dropped unauthenticated relay message from \(clientId)") return } @@ -1315,7 +1320,7 @@ class AstationHubManager: ObservableObject { response: AstationMessage ) { dispatchPrecondition(condition: .onQueue(.main)) - identityRelayAuthChallenges.removeValue(forKey: clientId) + identityRelayAuthChallenges.remove(clientId: clientId) authenticatedIdentityRelayClients.insert(clientId) sendHandler?(response, clientId) addClient(ConnectedClient( diff --git a/Sources/Menubar/DeviceAuthentication.swift b/Sources/Menubar/DeviceAuthentication.swift index c0a7344..00d8d9e 100644 --- a/Sources/Menubar/DeviceAuthentication.swift +++ b/Sources/Menubar/DeviceAuthentication.swift @@ -1,5 +1,7 @@ import CryptoKit +import Darwin import Foundation +import Security enum DeviceAuthentication { static let protocolVersion = "2" @@ -79,6 +81,10 @@ enum DeviceAuthentication { isBoundedText(value, maxBytes: maxPairingCodeBytes) } + static func isValidBootstrapToken(_ value: String) -> Bool { + isValidProof(value) + } + private static func canonicalMessage( challenge: String, astationId: String, @@ -129,6 +135,49 @@ enum DeviceAuthentication { } } +struct RelayAuthenticationChallengeStore { + private struct Entry { + let challenge: String + let expiresAt: Date + } + + private var entries: [String: Entry] = [:] + private let maxPending: Int + private let lifetime: TimeInterval + + init(maxPending: Int = 64, lifetime: TimeInterval = 120) { + self.maxPending = maxPending + self.lifetime = lifetime + } + + mutating func issue(clientId: String, challenge: String, now: Date = Date()) -> Bool { + removeExpired(now: now) + guard entries[clientId] != nil || entries.count < maxPending else { return false } + entries[clientId] = Entry( + challenge: challenge, + expiresAt: now.addingTimeInterval(lifetime) + ) + return true + } + + mutating func challenge(for clientId: String, now: Date = Date()) -> String? { + removeExpired(now: now) + return entries[clientId]?.challenge + } + + mutating func remove(clientId: String) { + entries.removeValue(forKey: clientId) + } + + mutating func removeAll() { + entries.removeAll() + } + + private mutating func removeExpired(now: Date) { + entries = entries.filter { $0.value.expiresAt > now } + } +} + /// A same-user secret shared by Astation and local Atem processes. It removes /// interactive pairing on loopback without trusting arbitrary browser pages. final class LocalBootstrapStore { @@ -149,15 +198,33 @@ final class LocalBootstrapStore { withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700] ) + let directoryValues = try baseDirectory.resourceValues(forKeys: [ + .isDirectoryKey, + .isSymbolicLinkKey + ]) + let directoryAttributes = try fileManager.attributesOfItem(atPath: baseDirectory.path) + guard directoryValues.isDirectory == true, + directoryValues.isSymbolicLink != true, + Self.isOwnedByCurrentUser(directoryAttributes) else { + throw LocalBootstrapStoreError.insecureDirectory + } try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: baseDirectory.path) fileURL = baseDirectory.appendingPathComponent(Self.filename) - if let existing = try? String(contentsOf: fileURL, encoding: .utf8) - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty { - token = existing - try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) - return + if Self.itemExists(at: fileURL, fileManager: fileManager) { + let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path) + if let attributes, + Self.isSecureTokenFile(fileURL, attributes: attributes), + let existing = try? String(contentsOf: fileURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + DeviceAuthentication.isValidBootstrapToken(existing) { + token = existing + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + return + } + + Log.warn("Replacing insecure or malformed local bootstrap token") + try fileManager.removeItem(at: fileURL) } let generated = DeviceAuthentication.randomHex(byteCount: 32) @@ -165,4 +232,32 @@ final class LocalBootstrapStore { try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) token = generated } + + private static func itemExists(at url: URL, fileManager: FileManager) -> Bool { + fileManager.fileExists(atPath: url.path) || + (try? fileManager.destinationOfSymbolicLink(atPath: url.path)) != nil + } + + private static func isSecureTokenFile( + _ url: URL, + attributes: [FileAttributeKey: Any] + ) -> Bool { + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue else { + return false + } + return values?.isRegularFile == true && + values?.isSymbolicLink != true && + isOwnedByCurrentUser(attributes) && + permissions & 0o077 == 0 + } + + private static func isOwnedByCurrentUser(_ attributes: [FileAttributeKey: Any]) -> Bool { + guard let owner = attributes[.ownerAccountID] as? NSNumber else { return false } + return owner.uint32Value == getuid() + } +} + +private enum LocalBootstrapStoreError: Error { + case insecureDirectory } diff --git a/Tests/AstationTests/DeviceAuthenticationTests.swift b/Tests/AstationTests/DeviceAuthenticationTests.swift index c1b1f8a..2fd7c2d 100644 --- a/Tests/AstationTests/DeviceAuthenticationTests.swift +++ b/Tests/AstationTests/DeviceAuthenticationTests.swift @@ -72,6 +72,61 @@ final class DeviceAuthenticationTests: XCTestCase { XCTAssertEqual(fileMode?.intValue, 0o600) } + func testBootstrapSecretRotatesWhenPermissionsAreInsecure() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AstationBootstrapPermissionTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let exposedToken = String(repeating: "a", count: 64) + let tokenURL = directory.appendingPathComponent(LocalBootstrapStore.filename) + try Data((exposedToken + "\n").utf8).write(to: tokenURL) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: tokenURL.path) + + let store = try LocalBootstrapStore(directory: directory) + let mode = try FileManager.default.attributesOfItem(atPath: tokenURL.path)[.posixPermissions] as? NSNumber + XCTAssertNotEqual(store.token, exposedToken) + XCTAssertEqual(mode?.intValue, 0o600) + } + + func testBootstrapSecretReplacesSymbolicLinkWithoutTouchingTarget() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AstationBootstrapSymlinkTests-\(UUID().uuidString)") + let directory = root.appendingPathComponent("store") + let target = root.appendingPathComponent("target") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let exposedToken = String(repeating: "b", count: 64) + try Data((exposedToken + "\n").utf8).write(to: target) + let tokenURL = directory.appendingPathComponent(LocalBootstrapStore.filename) + try FileManager.default.createSymbolicLink(at: tokenURL, withDestinationURL: target) + + let store = try LocalBootstrapStore(directory: directory) + let targetContents = try String(contentsOf: target, encoding: .utf8) + let tokenValues = try tokenURL.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + XCTAssertNotEqual(store.token, exposedToken) + XCTAssertEqual(targetContents, exposedToken + "\n") + XCTAssertEqual(tokenValues.isRegularFile, true) + XCTAssertNotEqual(tokenValues.isSymbolicLink, true) + } + + func testRelayAuthenticationChallengesAreBoundedAndExpire() { + let now = Date(timeIntervalSince1970: 1_000) + var store = RelayAuthenticationChallengeStore(maxPending: 2, lifetime: 10) + + XCTAssertTrue(store.issue(clientId: "atem-a", challenge: "challenge-a", now: now)) + XCTAssertTrue(store.issue(clientId: "atem-b", challenge: "challenge-b", now: now)) + XCTAssertFalse(store.issue(clientId: "atem-c", challenge: "challenge-c", now: now)) + XCTAssertEqual(store.challenge(for: "atem-a", now: now), "challenge-a") + XCTAssertNil(store.challenge(for: "atem-a", now: now.addingTimeInterval(11))) + XCTAssertTrue(store.issue( + clientId: "atem-c", + challenge: "challenge-c", + now: now.addingTimeInterval(11) + )) + } + func testSessionRequiresTokenProofAndMatchingDevice() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("AstationSessionTests-\(UUID().uuidString)") diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index 0ad9fc5..9514dd4 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -65,6 +65,9 @@ Authentication input is bounded before proof or UI processing: `atem_id` is at most 255 UTF-8 bytes, session and request IDs are at most 128 bytes, pairing codes are at most 32 bytes, and HMAC proofs are exactly 64 hexadecimal bytes. Empty values and control characters are rejected. +Relay challenges expire after two minutes and no more than 64 may be pending at +once. Astation rotates the same-user bootstrap token instead of trusting an +existing file with loose permissions, the wrong owner, or a symbolic-link path. ## Local state From e9a180a58d156d4bb12a1b3fc4b9c21c16c38d01 Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 22:48:41 -0700 Subject: [PATCH 07/10] fix: bind relay authentication identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require the relay envelope identity to match the authenticated Atem, preserve established authentication across repeated hello messages, and serialize pairing prompts. Keep protocol fields and UTF-8 bounds consistent across transports. 🤖 Built with SMT --- Sources/Menubar/AstationHubManager.swift | 28 +++++++++++++++---- Sources/Menubar/AstationWebSocketServer.swift | 12 +++++++- Sources/Menubar/DeviceAuthentication.swift | 19 +++++++++---- .../DeviceAuthenticationTests.swift | 19 +++++++++++++ .../2026-07-21-device-authentication-v2.md | 5 +++- 5 files changed, 69 insertions(+), 14 deletions(-) diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index 857624d..786111c 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -1204,6 +1204,11 @@ class AstationHubManager: ObservableObject { private func handleIdentityRelayMessage(_ msg: AstationMessage, task: URLSessionWebSocketTask, clientId: String) { dispatchPrecondition(condition: .onQueue(.main)) if case .statusUpdate(let status, let data) = msg, status == "hello" { + guard !authenticatedIdentityRelayClients.contains(clientId) else { + sendHandler?(.error(message: "Relay client is already authenticated"), clientId) + Log.warn("[AstationHub] Ignored repeated hello from authenticated relay client \(clientId)") + return + } let hostname = DeviceAuthentication.deviceLabel(data["hostname"] ?? "unknown") let challenge = DeviceAuthentication.makeChallenge() guard identityRelayAuthChallenges.issue(clientId: clientId, challenge: challenge) else { @@ -1211,7 +1216,6 @@ class AstationHubManager: ObservableObject { Log.warn("[AstationHub] Relay authentication challenge limit reached") return } - authenticatedIdentityRelayClients.remove(clientId) sendHandler?(.statusUpdate(status: "auth_required", data: [ "astation_id": AstationIdentity.shared.id, "challenge": challenge, @@ -1249,11 +1253,16 @@ class AstationHubManager: ObservableObject { return } + guard let atemId = data["atem_id"], + DeviceAuthentication.relayClientMatchesAtemId(clientId: clientId, atemId: atemId) else { + sendHandler?(.error(message: "Relay identity does not match authentication proof"), clientId) + Log.warn("[AstationHub] Rejected mismatched relay authentication identity for \(clientId)") + return + } + if let sessionId = data["session_id"], - let atemId = data["atem_id"], let proof = data["proof"], DeviceAuthentication.isValidSessionId(sessionId), - DeviceAuthentication.isValidAtemId(atemId), let session = deviceSessionStore.authenticate( sessionId: sessionId, atemId: atemId, @@ -1263,6 +1272,7 @@ class AstationHubManager: ObservableObject { ) { finishIdentityRelayAuthentication( clientId: clientId, + atemId: atemId, hostname: session.hostname, response: .statusUpdate(status: "authenticated", data: [ "method": "session_proof", @@ -1280,14 +1290,13 @@ class AstationHubManager: ObservableObject { guard let pairingCode = data["pairing_code"], let rawHostname = data["hostname"], - let atemId = data["atem_id"], - DeviceAuthentication.isValidPairingCode(pairingCode), - DeviceAuthentication.isValidAtemId(atemId) else { + DeviceAuthentication.isValidPairingCode(pairingCode) else { sendHandler?(.error(message: "Invalid relay authentication credentials"), clientId) return } let hostname = DeviceAuthentication.deviceLabel(rawHostname) + identityRelayAuthChallenges.remove(clientId: clientId) dispatchPrecondition(condition: .onQueue(.main)) let alert = NSAlert() alert.messageText = "Remote Atem Pairing Request" @@ -1304,6 +1313,7 @@ class AstationHubManager: ObservableObject { let session = deviceSessionStore.create(hostname: hostname, atemId: atemId) finishIdentityRelayAuthentication( clientId: clientId, + atemId: atemId, hostname: hostname, response: .auth(info: [ "status": "granted", @@ -1316,10 +1326,16 @@ class AstationHubManager: ObservableObject { private func finishIdentityRelayAuthentication( clientId: String, + atemId: String, hostname: String, response: AstationMessage ) { dispatchPrecondition(condition: .onQueue(.main)) + guard DeviceAuthentication.relayClientMatchesAtemId(clientId: clientId, atemId: atemId) else { + sendHandler?(.error(message: "Relay identity does not match authentication proof"), clientId) + Log.warn("[AstationHub] Refused to authenticate mismatched relay identity for \(clientId)") + return + } identityRelayAuthChallenges.remove(clientId: clientId) authenticatedIdentityRelayClients.insert(clientId) sendHandler?(response, clientId) diff --git a/Sources/Menubar/AstationWebSocketServer.swift b/Sources/Menubar/AstationWebSocketServer.swift index 6c22ca3..4e114a2 100644 --- a/Sources/Menubar/AstationWebSocketServer.swift +++ b/Sources/Menubar/AstationWebSocketServer.swift @@ -15,6 +15,7 @@ class AstationWebSocketServer { private let localBootstrapStore: LocalBootstrapStore? private var authenticatedClients: Set = [] // Client IDs that have been authenticated private var pendingAuthentication: [String: DirectAuthenticationContext] = [:] + private var pairingClients: Set = [] init( hubManager: AstationHubManager, @@ -102,6 +103,7 @@ class AstationWebSocketServer { self.connectedClients.removeValue(forKey: clientId) self.authenticatedClients.remove(clientId) self.pendingAuthentication.removeValue(forKey: clientId) + self.pairingClients.remove(clientId) self.hubManager.removeClient(withId: clientId) Log.info("WebSocket connection closed: \(clientId.prefix(8))") } @@ -156,6 +158,11 @@ class AstationWebSocketServer { } private func handleAuthMessage(_ message: AstationMessage, from clientId: String, ws: WebSocket) { + guard !pairingClients.contains(clientId) else { + sendMessage(.error(message: "Pairing approval is already pending"), to: clientId) + return + } + // Extract auth credentials from message guard case .statusUpdate(let status, let authInfo) = message, status == "auth" else { // Not an auth message - reject @@ -214,6 +221,7 @@ class AstationWebSocketServer { let atemId = authInfo["atem_id"], DeviceAuthentication.isValidPairingCode(pairingCode), DeviceAuthentication.isValidAtemId(atemId) { + pairingClients.insert(clientId) showPairingDialog( code: pairingCode, hostname: DeviceAuthentication.deviceLabel(rawHostname), @@ -354,6 +362,7 @@ class AstationWebSocketServer { let response = alert.runModal() ws.eventLoop.execute { + self.pairingClients.remove(clientId) guard self.connectedClients[clientId] != nil else { return } if response == .alertFirstButtonReturn { let session = self.sessionStore.create(hostname: hostname, atemId: atemId) @@ -362,7 +371,8 @@ class AstationWebSocketServer { self.sendMessage(.auth(info: [ "status": "granted", "session_id": session.id, - "token": session.token + "token": session.token, + "protocol": DeviceAuthentication.protocolVersion ]), to: clientId) self.registerClient(clientId, hostname: hostname) Log.info("✅ Pairing approved for \(hostname) (\(clientId.prefix(8)))") diff --git a/Sources/Menubar/DeviceAuthentication.swift b/Sources/Menubar/DeviceAuthentication.swift index 00d8d9e..6d4e2c5 100644 --- a/Sources/Menubar/DeviceAuthentication.swift +++ b/Sources/Menubar/DeviceAuthentication.swift @@ -15,15 +15,22 @@ enum DeviceAuthentication { } static func deviceLabel(_ value: String) -> String { - let cleaned = value.unicodeScalars.lazy - .filter { !CharacterSet.controlCharacters.contains($0) } - .prefix(255) - .map(String.init) - .joined() - .trimmingCharacters(in: .whitespacesAndNewlines) + var cleaned = "" + var byteCount = 0 + for scalar in value.unicodeScalars where !CharacterSet.controlCharacters.contains(scalar) { + let text = String(scalar) + guard byteCount + text.utf8.count <= 255 else { break } + cleaned.append(contentsOf: text) + byteCount += text.utf8.count + } + cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) return cleaned.isEmpty ? "unknown" : cleaned } + static func relayClientMatchesAtemId(clientId: String, atemId: String) -> Bool { + isValidAtemId(atemId) && clientId == "relay-\(atemId)" + } + static func proof( token: String, challenge: String, diff --git a/Tests/AstationTests/DeviceAuthenticationTests.swift b/Tests/AstationTests/DeviceAuthenticationTests.swift index 2fd7c2d..6b50bb0 100644 --- a/Tests/AstationTests/DeviceAuthenticationTests.swift +++ b/Tests/AstationTests/DeviceAuthenticationTests.swift @@ -11,6 +11,25 @@ final class DeviceAuthenticationTests: XCTestCase { XCTAssertFalse(label.contains("\n")) XCTAssertEqual(label.count, 255) XCTAssertEqual(DeviceAuthentication.deviceLabel("\n\t"), "unknown") + + let multibyteLabel = DeviceAuthentication.deviceLabel(String(repeating: "界", count: 100)) + XCTAssertEqual(multibyteLabel.utf8.count, 255) + XCTAssertEqual(multibyteLabel.count, 85) + } + + func testRelayClientIdentityMustMatchAuthenticatedAtemId() { + XCTAssertTrue(DeviceAuthentication.relayClientMatchesAtemId( + clientId: "relay-atem-office", + atemId: "atem-office" + )) + XCTAssertFalse(DeviceAuthentication.relayClientMatchesAtemId( + clientId: "relay-atem-cloud", + atemId: "atem-office" + )) + XCTAssertFalse(DeviceAuthentication.relayClientMatchesAtemId( + clientId: "relay-atem-office", + atemId: "atem-office\nspoof" + )) } func testProofMatchesProtocolVector() { diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index 9514dd4..62715ed 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -55,7 +55,10 @@ is the device session token and `session_id` is the saved session UUID. Astation compares proofs in constant time, binds legacy sessions to the first `atem_id` that proves possession, and rejects reuse with a different device ID. The relay path does not register `hello` as an authenticated client; `hello` -only causes Astation to issue a targeted challenge. +only causes Astation to issue a targeted challenge. The relay envelope +`atem_id` must exactly match the device ID in the authentication payload, and +an authenticated relay client cannot restart authentication with another +`hello` message. Application broadcasts are delivered to authenticated direct and identity-relay clients, while pending clients are excluded. Direct connection state is confined to the NIO event loop; relay authentication From a9f4f94658f620828a9116b80b7b29d8213159fe Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 23:15:08 -0700 Subject: [PATCH 08/10] fix: scope relay auth to socket generations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assign each relay WebSocket a server-generated connection ID, reject stale sockets and responses, and bind Astation authentication and delayed replies to that exact generation. Document the coordinated relay rollout and cover replacement cleanup. 🤖 Built with SMT --- Sources/Menubar/AstationHubManager.swift | 281 ++++++++++++++---- Sources/Menubar/DeviceAuthentication.swift | 85 ++++++ .../DeviceAuthenticationTests.swift | 42 +++ .../2026-05-28-remote-agent-control-design.md | 6 +- .../2026-07-21-device-authentication-v2.md | 7 + relay-server/src/relay.rs | 279 +++++++++++++++-- 6 files changed, 617 insertions(+), 83 deletions(-) diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index 786111c..b8b513a 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -42,8 +42,7 @@ class AstationHubManager: ObservableObject { /// NWPathMonitor for the identity relay — fires when network becomes available, /// enabling immediate reconnect without polling. Created once and reused. private var identityRelayPathMonitor: NWPathMonitor? - private var identityRelayAuthChallenges = RelayAuthenticationChallengeStore() - private var authenticatedIdentityRelayClients: Set = [] + private var identityRelayAuthentication = IdentityRelayAuthenticationState() /// Station relay URL. Priority: test override > ASTATION_RELAY_URL env var > UserDefaults > default. var stationRelayUrl: String { @@ -235,11 +234,19 @@ class AstationHubManager: ObservableObject { } /// Send credentialSync to one specific Atem (e.g. just-connected). - func sendCredentials(toClientId clientId: String) { - Task { await pushCredentials(targetClientId: clientId) } + func sendCredentials(toClientId clientId: String, relayConnectionId: String? = nil) { + Task { + await pushCredentials( + targetClientId: clientId, + relayConnectionId: relayConnectionId + ) + } } - private func pushCredentials(targetClientId: String?) async { + private func pushCredentials( + targetClientId: String?, + relayConnectionId: String? = nil + ) async { do { _ = try await tokenProvider.validToken() } catch { Log.info("[AstationHub] No session — skipping credentialSync (\(error.localizedDescription))") @@ -255,7 +262,7 @@ class AstationHubManager: ObservableObject { saveCredentials: false ) if let id = targetClientId { - sendHandler?(msg, id) + sendMessage(msg, to: id, expectedRelayConnectionId: relayConnectionId) Log.info("[AstationHub] Sent credentialSync to \(id.prefix(8))…") } else { broadcastHandler?(msg) @@ -412,13 +419,25 @@ class AstationHubManager: ObservableObject { // MARK: - Client Management - func addClient(_ client: ConnectedClient) { + func addClient(_ client: ConnectedClient, relayConnectionId: String? = nil) { DispatchQueue.main.async { + if let relayConnectionId { + guard self.identityRelayAuthentication.isAuthenticated( + clientId: client.id, + connectionId: relayConnectionId + ) else { + Log.warn("[AstationHub] Ignored stale relay client registration") + return + } + } self.connectedClients.append(client) Log.info(" Client connected: \(client.id) (\(client.clientType))") // Send credentials immediately after connection - self.sendCredentials(toClientId: client.id) + self.sendCredentials( + toClientId: client.id, + relayConnectionId: relayConnectionId + ) self.broadcastInstanceList() } @@ -482,7 +501,11 @@ class AstationHubManager: ObservableObject { // MARK: - Message Handling - func handleMessage(_ message: AstationMessage, from clientId: String) -> AstationMessage? { + func handleMessage( + _ message: AstationMessage, + from clientId: String, + relayConnectionId: String? = nil + ) -> AstationMessage? { switch message { case .projectListRequest: Log.info(" Project list requested by client: \(clientId)") @@ -495,7 +518,11 @@ class AstationHubManager: ObservableObject { let response = AstationMessage.tokenResponse( token: tokenResponse.token, channel: tokenResponse.channel, uid: tokenResponse.uid, expiresIn: tokenResponse.expiresIn, timestamp: Date()) - self.sendHandler?(response, clientId) + self.sendMessage( + response, + to: clientId, + expectedRelayConnectionId: relayConnectionId + ) } return nil @@ -510,7 +537,8 @@ class AstationHubManager: ObservableObject { updateClientActivity( clientId: clientId, hostname: data["hostname"], - tag: data["tag"] + tag: data["tag"], + relayConnectionId: relayConnectionId ) return nil @@ -713,7 +741,12 @@ class AstationHubManager: ObservableObject { // MARK: - Atem Instance Management /// Update a connected client's metadata from a status update. - func updateClientActivity(clientId: String, hostname: String?, tag: String?) { + func updateClientActivity( + clientId: String, + hostname: String?, + tag: String?, + relayConnectionId: String? = nil + ) { DispatchQueue.main.async { guard let index = self.connectedClients.firstIndex(where: { $0.id == clientId }) else { return } @@ -730,10 +763,14 @@ class AstationHubManager: ObservableObject { } // Ask this Atem to send its current agent list. - sendHandler?(AstationMessage.agentListRequest, clientId) + sendMessage( + AstationMessage.agentListRequest, + to: clientId, + expectedRelayConnectionId: relayConnectionId + ) // Push credentials to the newly connected Atem. - sendCredentials(toClientId: clientId) + sendCredentials(toClientId: clientId, relayConnectionId: relayConnectionId) } /// Mark the most-recently-active client as focused, unfocus others. @@ -1108,18 +1145,41 @@ class AstationHubManager: ObservableObject { task.resume() // Wire sendHandler: route messages whose clientId starts with "relay-" through - // the identity relay WS as an envelope: {"atem_id":"","payload":}. + // the identity relay WS with the current Atem socket generation. // Multiple Atems can be connected simultaneously; each has its own "relay-" clientId. let originalSend = sendHandler - sendHandler = { [weak task] message, targetId in + sendHandler = { [weak self, weak task] message, targetId in if targetId.hasPrefix("relay-") { - let atemId = String(targetId.dropFirst(6)) // strip "relay-" prefix - guard let payloadData = try? JSONEncoder().encode(message), - let payloadObj = try? JSONSerialization.jsonObject(with: payloadData), - let envelope = try? JSONSerialization.data(withJSONObject: ["atem_id": atemId, "payload": payloadObj]), - let envelopeStr = String(data: envelope, encoding: .utf8) else { return } - NetworkDebugLogger.logWebSocket(direction: "send", context: "identity-relay:\(atemId)", message: envelopeStr) - task?.send(.string(envelopeStr)) { _ in } + let sendToRelay = { [weak self, weak task] in + let atemId = String(targetId.dropFirst(6)) // strip "relay-" prefix + guard let self, + let connectionId = self.identityRelayAuthentication.connectionId(for: targetId) else { + Log.warn("[AstationHub] Cannot route to relay client without an active connection") + return + } + guard self.identityRelayAuthentication.isAuthenticated( + clientId: targetId, + connectionId: connectionId + ) || Self.isRelayAuthenticationControl(message) else { + Log.warn("[AstationHub] Dropped application message for unauthenticated relay client") + return + } + guard let payloadData = try? JSONEncoder().encode(message), + let payloadObj = try? JSONSerialization.jsonObject(with: payloadData), + let envelope = try? JSONSerialization.data(withJSONObject: [ + "atem_id": atemId, + "connection_id": connectionId, + "payload": payloadObj + ]), + let envelopeStr = String(data: envelope, encoding: .utf8) else { return } + NetworkDebugLogger.logWebSocket(direction: "send", context: "identity-relay:\(atemId)", message: envelopeStr) + task?.send(.string(envelopeStr)) { _ in } + } + if Thread.isMainThread { + sendToRelay() + } else { + DispatchQueue.main.async(execute: sendToRelay) + } } else { originalSend?(message, targetId) } @@ -1158,18 +1218,32 @@ class AstationHubManager: ObservableObject { switch message { case .string(let text): NetworkDebugLogger.logWebSocket(direction: "recv", context: "identity-relay", message: text) - // Relay wraps Atem messages as {"atem_id":"","payload":{...}} - // Extract atem_id and decode payload, then use "relay-" as clientId. + // The relay binds each envelope to its current Atem WebSocket generation. if let data = text.data(using: .utf8), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let atemId = envelope["atem_id"] as? String, DeviceAuthentication.isValidAtemId(atemId), - let payloadObj = envelope["payload"], - let payloadData = try? JSONSerialization.data(withJSONObject: payloadObj), - let msg = try? JSONDecoder().decode(AstationMessage.self, from: payloadData) { + let connectionId = envelope["connection_id"] as? String, + DeviceAuthentication.isValidRelayConnectionId(connectionId) { let relayClientId = "relay-\(atemId)" - DispatchQueue.main.async { - self?.handleIdentityRelayMessage(msg, task: task, clientId: relayClientId) + if let event = envelope["relay_event"] as? String { + DispatchQueue.main.async { + self?.handleIdentityRelayConnectionEvent( + event, + clientId: relayClientId, + connectionId: connectionId + ) + } + } else if let payloadObj = envelope["payload"], + let payloadData = try? JSONSerialization.data(withJSONObject: payloadObj), + let msg = try? JSONDecoder().decode(AstationMessage.self, from: payloadData) { + DispatchQueue.main.async { + self?.handleIdentityRelayMessage( + msg, + clientId: relayClientId, + connectionId: connectionId + ) + } } } default: @@ -1184,8 +1258,7 @@ class AstationHubManager: ObservableObject { self?.connectedClients .filter { $0.id.hasPrefix("relay-") } .forEach { self?.removeClient(withId: $0.id) } - self?.identityRelayAuthChallenges.removeAll() - self?.authenticatedIdentityRelayClients.removeAll() + self?.identityRelayAuthentication.removeAll() self?.identityRelayActive = false } // Schedule a 30s fallback retry (only if network is still up). @@ -1201,17 +1274,52 @@ class AstationHubManager: ObservableObject { } } - private func handleIdentityRelayMessage(_ msg: AstationMessage, task: URLSessionWebSocketTask, clientId: String) { + private func handleIdentityRelayConnectionEvent( + _ event: String, + clientId: String, + connectionId: String + ) { + dispatchPrecondition(condition: .onQueue(.main)) + switch event { + case "connected": + if identityRelayAuthentication.connect(clientId: clientId, connectionId: connectionId) { + removeClient(withId: clientId) + } + case "disconnected": + if identityRelayAuthentication.disconnect(clientId: clientId, connectionId: connectionId) { + removeClient(withId: clientId) + } + default: + Log.warn("[AstationHub] Ignored unknown identity relay event: \(event)") + } + } + + private func handleIdentityRelayMessage( + _ msg: AstationMessage, + clientId: String, + connectionId: String + ) { dispatchPrecondition(condition: .onQueue(.main)) + if identityRelayAuthentication.connect(clientId: clientId, connectionId: connectionId) { + removeClient(withId: clientId) + } + if case .statusUpdate(let status, let data) = msg, status == "hello" { - guard !authenticatedIdentityRelayClients.contains(clientId) else { + guard !identityRelayAuthentication.isAuthenticated( + clientId: clientId, + connectionId: connectionId + ) else { sendHandler?(.error(message: "Relay client is already authenticated"), clientId) Log.warn("[AstationHub] Ignored repeated hello from authenticated relay client \(clientId)") return } let hostname = DeviceAuthentication.deviceLabel(data["hostname"] ?? "unknown") let challenge = DeviceAuthentication.makeChallenge() - guard identityRelayAuthChallenges.issue(clientId: clientId, challenge: challenge) else { + guard identityRelayAuthentication.issueChallenge( + clientId: clientId, + connectionId: connectionId, + challenge: challenge + ) else { sendHandler?(.error(message: "Too many pending relay authentication requests"), clientId) Log.warn("[AstationHub] Relay authentication challenge limit reached") return @@ -1227,28 +1335,46 @@ class AstationHubManager: ObservableObject { return } - if !authenticatedIdentityRelayClients.contains(clientId) { - handleIdentityRelayAuthentication(msg, clientId: clientId) + if !identityRelayAuthentication.isAuthenticated( + clientId: clientId, + connectionId: connectionId + ) { + handleIdentityRelayAuthentication( + msg, + clientId: clientId, + connectionId: connectionId + ) return } - if let response = handleMessage(msg, from: clientId) { - sendHandler?(response, clientId) + if let response = handleMessage( + msg, + from: clientId, + relayConnectionId: connectionId + ) { + sendMessage(response, to: clientId, expectedRelayConnectionId: connectionId) } } func broadcastToAuthenticatedIdentityRelayClients(_ message: AstationMessage) { dispatchPrecondition(condition: .onQueue(.main)) - for clientId in authenticatedIdentityRelayClients { + for clientId in identityRelayAuthentication.authenticatedClientIds { sendHandler?(message, clientId) } } - private func handleIdentityRelayAuthentication(_ msg: AstationMessage, clientId: String) { + private func handleIdentityRelayAuthentication( + _ msg: AstationMessage, + clientId: String, + connectionId: String + ) { dispatchPrecondition(condition: .onQueue(.main)) guard case .statusUpdate(let status, let data) = msg, status == "auth", - let challenge = identityRelayAuthChallenges.challenge(for: clientId) else { + let challenge = identityRelayAuthentication.challenge( + clientId: clientId, + connectionId: connectionId + ) else { Log.warn("[AstationHub] Dropped unauthenticated relay message from \(clientId)") return } @@ -1273,6 +1399,7 @@ class AstationHubManager: ObservableObject { finishIdentityRelayAuthentication( clientId: clientId, atemId: atemId, + connectionId: connectionId, hostname: session.hostname, response: .statusUpdate(status: "authenticated", data: [ "method": "session_proof", @@ -1296,7 +1423,6 @@ class AstationHubManager: ObservableObject { } let hostname = DeviceAuthentication.deviceLabel(rawHostname) - identityRelayAuthChallenges.remove(clientId: clientId) dispatchPrecondition(condition: .onQueue(.main)) let alert = NSAlert() alert.messageText = "Remote Atem Pairing Request" @@ -1306,14 +1432,20 @@ class AstationHubManager: ObservableObject { alert.alertStyle = .informational guard alert.runModal() == .alertFirstButtonReturn else { + identityRelayAuthentication.reject(clientId: clientId, connectionId: connectionId) sendHandler?(.auth(info: ["status": "denied", "message": "Pairing denied by user"]), clientId) return } + guard identityRelayAuthentication.connectionId(for: clientId) == connectionId else { + Log.warn("[AstationHub] Relay connection changed while pairing approval was pending") + return + } let session = deviceSessionStore.create(hostname: hostname, atemId: atemId) finishIdentityRelayAuthentication( clientId: clientId, atemId: atemId, + connectionId: connectionId, hostname: hostname, response: .auth(info: [ "status": "granted", @@ -1327,26 +1459,69 @@ class AstationHubManager: ObservableObject { private func finishIdentityRelayAuthentication( clientId: String, atemId: String, + connectionId: String, hostname: String, response: AstationMessage ) { dispatchPrecondition(condition: .onQueue(.main)) - guard DeviceAuthentication.relayClientMatchesAtemId(clientId: clientId, atemId: atemId) else { + guard identityRelayAuthentication.authenticate( + clientId: clientId, + atemId: atemId, + connectionId: connectionId + ) else { sendHandler?(.error(message: "Relay identity does not match authentication proof"), clientId) - Log.warn("[AstationHub] Refused to authenticate mismatched relay identity for \(clientId)") + Log.warn("[AstationHub] Refused stale or mismatched relay authentication for \(clientId)") return } - identityRelayAuthChallenges.remove(clientId: clientId) - authenticatedIdentityRelayClients.insert(clientId) sendHandler?(response, clientId) - addClient(ConnectedClient( - id: clientId, - clientType: "Atem", - connectedAt: Date(), - hostname: "relay:\(hostname)" - )) + addClient( + ConnectedClient( + id: clientId, + clientType: "Atem", + connectedAt: Date(), + hostname: "relay:\(hostname)" + ), + relayConnectionId: connectionId + ) Log.info("[AstationHub] Authenticated relay Atem: \(hostname)") } + + private func sendMessage( + _ message: AstationMessage, + to clientId: String, + expectedRelayConnectionId: String? + ) { + guard clientId.hasPrefix("relay-"), let expectedRelayConnectionId else { + sendHandler?(message, clientId) + return + } + + let sendIfCurrent = { [weak self] in + guard let self, + self.identityRelayAuthentication.connectionId(for: clientId) == expectedRelayConnectionId else { + Log.warn("[AstationHub] Dropped response for replaced relay connection") + return + } + self.sendHandler?(message, clientId) + } + if Thread.isMainThread { + sendIfCurrent() + } else { + DispatchQueue.main.async(execute: sendIfCurrent) + } + } + + private static func isRelayAuthenticationControl(_ message: AstationMessage) -> Bool { + switch message { + case .statusUpdate(let status, _): + return status == "auth_required" || + status == "authenticated" || + status == "auth" || + status == "error" + default: + return false + } + } } // MARK: - Data Models diff --git a/Sources/Menubar/DeviceAuthentication.swift b/Sources/Menubar/DeviceAuthentication.swift index 6d4e2c5..e137993 100644 --- a/Sources/Menubar/DeviceAuthentication.swift +++ b/Sources/Menubar/DeviceAuthentication.swift @@ -9,6 +9,7 @@ enum DeviceAuthentication { static let maxSessionIdBytes = 128 static let maxRequestIdBytes = 128 static let maxPairingCodeBytes = 32 + static let maxRelayConnectionIdBytes = 64 static func makeChallenge() -> String { randomHex(byteCount: 32) @@ -31,6 +32,10 @@ enum DeviceAuthentication { isValidAtemId(atemId) && clientId == "relay-\(atemId)" } + static func isValidRelayConnectionId(_ value: String) -> Bool { + isBoundedText(value, maxBytes: maxRelayConnectionIdBytes) && UUID(uuidString: value) != nil + } + static func proof( token: String, challenge: String, @@ -185,6 +190,86 @@ struct RelayAuthenticationChallengeStore { } } +struct IdentityRelayAuthenticationState { + private var connectionIds: [String: String] = [:] + private var authenticatedConnectionIds: [String: String] = [:] + private var challenges = RelayAuthenticationChallengeStore() + + mutating func connect(clientId: String, connectionId: String) -> Bool { + let previous = connectionIds.updateValue(connectionId, forKey: clientId) + guard previous != connectionId else { return false } + challenges.remove(clientId: clientId) + authenticatedConnectionIds.removeValue(forKey: clientId) + return previous != nil + } + + mutating func disconnect(clientId: String, connectionId: String) -> Bool { + guard connectionIds[clientId] == connectionId else { return false } + connectionIds.removeValue(forKey: clientId) + authenticatedConnectionIds.removeValue(forKey: clientId) + challenges.remove(clientId: clientId) + return true + } + + mutating func issueChallenge( + clientId: String, + connectionId: String, + challenge: String + ) -> Bool { + guard connectionIds[clientId] == connectionId, + !isAuthenticated(clientId: clientId, connectionId: connectionId) else { + return false + } + return challenges.issue(clientId: clientId, challenge: challenge) + } + + mutating func challenge(clientId: String, connectionId: String) -> String? { + guard connectionIds[clientId] == connectionId else { return nil } + return challenges.challenge(for: clientId) + } + + mutating func authenticate( + clientId: String, + atemId: String, + connectionId: String + ) -> Bool { + guard DeviceAuthentication.relayClientMatchesAtemId(clientId: clientId, atemId: atemId), + connectionIds[clientId] == connectionId, + challenges.challenge(for: clientId) != nil else { + return false + } + challenges.remove(clientId: clientId) + authenticatedConnectionIds[clientId] = connectionId + return true + } + + mutating func reject(clientId: String, connectionId: String) { + guard connectionIds[clientId] == connectionId else { return } + challenges.remove(clientId: clientId) + } + + func isAuthenticated(clientId: String, connectionId: String) -> Bool { + connectionIds[clientId] == connectionId && + authenticatedConnectionIds[clientId] == connectionId + } + + var authenticatedClientIds: [String] { + authenticatedConnectionIds.compactMap { clientId, connectionId in + connectionIds[clientId] == connectionId ? clientId : nil + } + } + + func connectionId(for clientId: String) -> String? { + connectionIds[clientId] + } + + mutating func removeAll() { + connectionIds.removeAll() + authenticatedConnectionIds.removeAll() + challenges.removeAll() + } +} + /// A same-user secret shared by Astation and local Atem processes. It removes /// interactive pairing on loopback without trusting arbitrary browser pages. final class LocalBootstrapStore { diff --git a/Tests/AstationTests/DeviceAuthenticationTests.swift b/Tests/AstationTests/DeviceAuthenticationTests.swift index 6b50bb0..0a1e2b4 100644 --- a/Tests/AstationTests/DeviceAuthenticationTests.swift +++ b/Tests/AstationTests/DeviceAuthenticationTests.swift @@ -30,6 +30,48 @@ final class DeviceAuthenticationTests: XCTestCase { clientId: "relay-atem-office", atemId: "atem-office\nspoof" )) + XCTAssertTrue(DeviceAuthentication.isValidRelayConnectionId( + "43c8a181-6567-49ae-9191-8e103a66cc55" + )) + XCTAssertFalse(DeviceAuthentication.isValidRelayConnectionId("connection-one")) + } + + func testRelayAuthenticationIsBoundToConnectionGeneration() { + let clientId = "relay-atem-office" + let firstConnection = "43c8a181-6567-49ae-9191-8e103a66cc55" + let replacementConnection = "328e433e-82c0-4d54-9241-503de8ff55dd" + var state = IdentityRelayAuthenticationState() + + XCTAssertFalse(state.connect(clientId: clientId, connectionId: firstConnection)) + XCTAssertTrue(state.issueChallenge( + clientId: clientId, + connectionId: firstConnection, + challenge: "first-challenge" + )) + XCTAssertTrue(state.authenticate( + clientId: clientId, + atemId: "atem-office", + connectionId: firstConnection + )) + XCTAssertTrue(state.isAuthenticated(clientId: clientId, connectionId: firstConnection)) + + XCTAssertTrue(state.connect(clientId: clientId, connectionId: replacementConnection)) + XCTAssertFalse(state.isAuthenticated(clientId: clientId, connectionId: replacementConnection)) + XCTAssertNil(state.challenge(clientId: clientId, connectionId: firstConnection)) + XCTAssertFalse(state.disconnect(clientId: clientId, connectionId: firstConnection)) + XCTAssertEqual(state.connectionId(for: clientId), replacementConnection) + + XCTAssertTrue(state.issueChallenge( + clientId: clientId, + connectionId: replacementConnection, + challenge: "replacement-challenge" + )) + XCTAssertTrue(state.authenticate( + clientId: clientId, + atemId: "atem-office", + connectionId: replacementConnection + )) + XCTAssertTrue(state.isAuthenticated(clientId: clientId, connectionId: replacementConnection)) } func testProofMatchesProtocolVector() { diff --git a/docs/specs/2026-05-28-remote-agent-control-design.md b/docs/specs/2026-05-28-remote-agent-control-design.md index f839779..45528aa 100644 --- a/docs/specs/2026-05-28-remote-agent-control-design.md +++ b/docs/specs/2026-05-28-remote-agent-control-design.md @@ -28,7 +28,7 @@ session running under atem on the target machine." The hard parts already exist in Astation: -- **Transport + targeting + relay envelope** — `AstationHubManager.sendHandler?(message, targetId)` is the universal send. For relay clients (`targetId == "relay-"`) it already wraps the message as `{"atem_id": "", "payload": }` and sends it over the identity relay; for direct clients it sends as-is. `routeToFocusedAtem()` picks the target atem (pinned or focused). +- **Transport + targeting + relay envelope** — `AstationHubManager.sendHandler?(message, targetId)` is the universal send. For relay clients (`targetId == "relay-"`) it wraps the message with the Atem ID, the relay-assigned connection generation, and the payload; for direct clients it sends as-is. `routeToFocusedAtem()` picks the target Atem. - **Voice** — `VoiceCodingManager` + `sendVoiceCommand(text:isFinal:)` already do mic → ConvoAI ASR → `voiceCommand` / `voiceRequest` → atem. **Reuse as-is.** No new voice work in v1. - **Message plumbing** — `AstationMessage` (tagged `type`/`data` enum) with manual `Codable`. See CLAUDE.md → "Adding a New Message Type". @@ -41,10 +41,10 @@ method mirroring `sendVoiceCommand`, and a minimal UI to enter it. routes by `atem_id`): ```json -{ "atem_id": "", "payload": } +{ "atem_id": "", "connection_id": "", "payload": } ``` -So **`atem_id` is the envelope's job — do NOT put it inside the message payload.** +So **`atem_id` and `connection_id` are the envelope's job — do NOT put them inside the message payload.** The `agentInput` payload carries only the agent selector + the input: ```json diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index 62715ed..23b411e 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -59,6 +59,11 @@ only causes Astation to issue a targeted challenge. The relay envelope `atem_id` must exactly match the device ID in the authentication payload, and an authenticated relay client cannot restart authentication with another `hello` message. +The relay assigns every Atem WebSocket a random `connection_id`, announces +connect and disconnect events to Astation, and includes both IDs in every +envelope. Astation binds the challenge and authenticated state to that exact +connection generation. It echoes the `connection_id` on targeted responses; +the relay drops messages from stale sockets and responses for replaced sockets. Application broadcasts are delivered to authenticated direct and identity-relay clients, while pending clients are excluded. Direct connection state is confined to the NIO event loop; relay authentication @@ -112,6 +117,8 @@ No DNS lookup, relay request, or internet service is required on this path. 1. Merge both repository PRs before releasing either binary. 2. Release Astation and Atem as a coordinated version pair. + Deploy the matching relay build before enabling identity-relay v2 because + Astation fails closed on envelopes without a `connection_id`. 3. Existing session records remain readable, but old clients that send only a session ID cannot authenticate against v2. 4. On `pairing required`, the updated Atem retries interactive pairing on the diff --git a/relay-server/src/relay.rs b/relay-server/src/relay.rs index f34e377..45f75b7 100644 --- a/relay-server/src/relay.rs +++ b/relay-server/src/relay.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{mpsc, RwLock}; use tokio::time::Instant; +use uuid::Uuid; use validator::Validate; use crate::session_verify::SessionVerifyCache; @@ -37,13 +38,47 @@ struct PairRoom { hostname: String, /// One sender per connected Atem, keyed by atem_id. /// Multiple Atems can be connected to the same room simultaneously. - atem_txs: HashMap>, + atem_txs: HashMap, /// Session claims waiting for a targeted Astation auth response. pending_session_ids: HashMap, astation_tx: Option>, + astation_connection_id: Option, created_at: Instant, } +#[derive(Clone)] +struct AtemConnection { + connection_id: String, + tx: mpsc::UnboundedSender, +} + +impl PairRoom { + fn is_current_atem_connection(&self, atem_id: &str, connection_id: &str) -> bool { + self.atem_txs + .get(atem_id) + .map(|connection| connection.connection_id == connection_id) + .unwrap_or(false) + } + + fn remove_atem_if_current(&mut self, atem_id: &str, connection_id: &str) -> bool { + if !self.is_current_atem_connection(atem_id, connection_id) { + return false; + } + self.atem_txs.remove(atem_id); + self.pending_session_ids.remove(atem_id); + true + } +} + +fn relay_connection_event(atem_id: &str, connection_id: &str, event: &str) -> String { + serde_json::json!({ + "atem_id": atem_id, + "connection_id": connection_id, + "relay_event": event, + }) + .to_string() +} + #[derive(Clone)] pub struct RelayHub { rooms: Arc>>, @@ -157,6 +192,7 @@ pub async fn create_pair_handler( atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now(), }; @@ -252,6 +288,7 @@ pub async fn ws_handler( atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now(), }, ); @@ -293,6 +330,7 @@ pub async fn ws_handler( atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now(), } }); @@ -341,8 +379,8 @@ fn sanitize_atem_id(raw: Option<&str>) -> String { /// Message routing protocol for multi-Atem rooms: /// -/// Atem → Astation: relay WRAPS `{"atem_id":"","payload":}` -/// Astation → Atem: Astation sends `{"atem_id":"","payload":}` → relay UNWRAPS, routes to that Atem +/// Atem → Astation: relay wraps the message with `atem_id` and a per-socket `connection_id`. +/// Astation → Atem: Astation echoes both IDs with the payload; stale generations are dropped. /// Astation → ALL: Astation sends raw JSON (no `atem_id` key) → relay BROADCASTS to every Atem in the room async fn handle_ws( hub: RelayHub, @@ -354,9 +392,10 @@ async fn handle_ws( ) { let (mut ws_sink, mut ws_stream) = socket.split(); let (tx, mut rx) = mpsc::unbounded_channel::(); + let connection_id = Uuid::new_v4().to_string(); // Register this side's sender in the room - { + let startup_notifications = { let mut rooms = hub.rooms.write().await; let room = match rooms.get_mut(&code) { Some(r) => r, @@ -366,20 +405,51 @@ async fn handle_ws( } }; + let mut notifications = Vec::new(); match role.as_str() { "atem" => { - room.atem_txs.insert(atem_id.clone(), tx.clone()); + room.pending_session_ids.remove(&atem_id); + room.atem_txs.insert( + atem_id.clone(), + AtemConnection { + connection_id: connection_id.clone(), + tx: tx.clone(), + }, + ); + if let Some(astation_tx) = room.astation_tx.clone() { + notifications.push(( + astation_tx, + relay_connection_event(&atem_id, &connection_id, "connected"), + )); + } } "astation" => { room.astation_tx = Some(tx.clone()); + room.astation_connection_id = Some(connection_id.clone()); + notifications.extend(room.atem_txs.iter().map(|(atem_id, connection)| { + ( + tx.clone(), + relay_connection_event( + atem_id, + &connection.connection_id, + "connected", + ), + ) + })); } _ => { tracing::warn!("Unknown role: {}", role); return; } } + notifications }; + for (target, notification) in startup_notifications { + let _ = target.send(notification); + } + drop(tx); + tracing::info!("WS connected: role={} code={}", role, code); // Task: forward messages from our channel to the WS sink, with periodic pings @@ -405,7 +475,10 @@ async fn handle_ws( break; } } - None => break, // channel closed + None => { + let _ = ws_sink.close().await; + break; + } } } _ = ping_interval.tick() => { @@ -427,9 +500,9 @@ async fn handle_ws( // are detected and cleaned up within 90s rather than waiting for the OS TCP timeout. // // Routing rules (see handle_ws comment above for full protocol spec): - // - Atem → relay: raw msg → relay wraps {"atem_id":id,"payload":msg} → Astation + // - Atem → relay: raw msg → relay adds atem_id and connection_id → Astation // - Astation → relay: - // {"atem_id":"x","payload":msg} → unwrap, forward payload to Atem "x" + // targeted envelope with both IDs → forward only to that exact socket generation // raw msg (no atem_id) → broadcast payload to ALL Atems in room let hub_for_read = hub.clone(); let role_for_read = role.clone(); @@ -449,6 +522,26 @@ async fn handle_ws( Ok(axum::extract::ws::Message::Text(text)) => { match role_for_read.as_str() { "atem" => { + let is_current = { + let rooms = hub_for_read.rooms.read().await; + rooms + .get(&code_for_read) + .map(|room| { + room.is_current_atem_connection( + &atem_id_for_read, + &connection_id, + ) + }) + .unwrap_or(false) + }; + if !is_current { + tracing::debug!( + "Dropping stale Atem connection: code={} atem_id={}", + code_for_read, + atem_id_for_read + ); + break; + } record_atem_auth_attempt( &hub_for_read, &code_for_read, @@ -465,21 +558,69 @@ async fn handle_ws( if let Some(tx) = astation_tx { // Parse payload as JSON value so serde handles escaping correctly let envelope = if let Ok(payload) = serde_json::from_str::(&text) { - serde_json::json!({"atem_id": atem_id_for_read, "payload": payload}).to_string() + serde_json::json!({ + "atem_id": atem_id_for_read, + "connection_id": connection_id.as_str(), + "payload": payload + }).to_string() } else { // Fallback: treat as raw string payload - serde_json::json!({"atem_id": atem_id_for_read, "payload": text}).to_string() + serde_json::json!({ + "atem_id": atem_id_for_read, + "connection_id": connection_id.as_str(), + "payload": text + }).to_string() }; let _ = tx.send(envelope); } } "astation" => { - // Parse envelope: {"atem_id":"x","payload":{...}} → route to Atem x + let is_current = { + let rooms = hub_for_read.rooms.read().await; + rooms + .get(&code_for_read) + .and_then(|room| room.astation_connection_id.as_deref()) + .map(|current| current == connection_id) + .unwrap_or(false) + }; + if !is_current { + tracing::debug!("Dropping stale Astation connection: code={}", code_for_read); + break; + } + // Parse a generation-bound envelope and route it to the current Atem socket. // Or raw JSON (no atem_id) → broadcast to all Atems if let Ok(env) = serde_json::from_str::(&text) { if let Some(target_id) = env.get("atem_id").and_then(|v| v.as_str()) { + let Some(requested_connection_id) = env + .get("connection_id") + .and_then(|value| value.as_str()) else { + tracing::debug!( + "Dropping generationless targeted message: code={} atem_id={}", + code_for_read, + target_id + ); + continue; + }; // Targeted: route payload to the specific Atem let payload = env.get("payload").cloned().unwrap_or(serde_json::Value::Null); + let target_connection = { + let rooms = hub_for_read.rooms.read().await; + rooms + .get(&code_for_read) + .and_then(|room| room.atem_txs.get(target_id)) + .filter(|connection| { + requested_connection_id == connection.connection_id + }) + .cloned() + }; + let Some(target_connection) = target_connection else { + tracing::debug!( + "Dropping message for stale or missing Atem connection: code={} atem_id={}", + code_for_read, + target_id + ); + continue; + }; observe_astation_auth_response( &hub_for_read, &verify_cache, @@ -489,20 +630,18 @@ async fn handle_ws( ) .await; let payload_str = payload.to_string(); - let target_tx = { - let rooms = hub_for_read.rooms.read().await; - rooms.get(&code_for_read) - .and_then(|r| r.atem_txs.get(target_id).cloned()) - }; - if let Some(tx) = target_tx { - let _ = tx.send(payload_str); - } + let _ = target_connection.tx.send(payload_str); } else { // Broadcast: send raw message to all Atems let txs: Vec<_> = { let rooms = hub_for_read.rooms.read().await; rooms.get(&code_for_read) - .map(|r| r.atem_txs.values().cloned().collect()) + .map(|r| { + r.atem_txs + .values() + .map(|connection| connection.tx.clone()) + .collect() + }) .unwrap_or_default() }; for tx in txs { @@ -525,17 +664,32 @@ async fn handle_ws( } // Cleanup: remove our sender from the room - { + let disconnect_notification = { let mut rooms = hub_for_read.rooms.write().await; + let mut notification = None; if let Some(room) = rooms.get_mut(&code) { match role.as_str() { "atem" => { - room.atem_txs.remove(&atem_id); - room.pending_session_ids.remove(&atem_id); + if room.remove_atem_if_current(&atem_id, &connection_id) { + notification = room.astation_tx.clone().map(|astation_tx| { + ( + astation_tx, + relay_connection_event(&atem_id, &connection_id, "disconnected"), + ) + }); + } } "astation" => { - room.astation_tx = None; - room.pending_session_ids.clear(); + if room + .astation_connection_id + .as_deref() + .map(|current| current == connection_id) + .unwrap_or(false) + { + room.astation_tx = None; + room.astation_connection_id = None; + room.pending_session_ids.clear(); + } } _ => {} } @@ -545,6 +699,11 @@ async fn handle_ws( tracing::info!("Room {} removed (all sides disconnected)", code); } } + notification + }; + + if let Some((target, notification)) = disconnect_notification { + let _ = target.send(notification); } write_task.abort(); @@ -901,6 +1060,53 @@ mod tests { assert!(id2.starts_with("atem-")); } + #[test] + fn relay_connection_event_carries_generation() { + let event = relay_connection_event( + "atem-office", + "43c8a181-6567-49ae-9191-8e103a66cc55", + "connected", + ); + let value: serde_json::Value = serde_json::from_str(&event).unwrap(); + + assert_eq!(value["atem_id"], "atem-office"); + assert_eq!(value["connection_id"], "43c8a181-6567-49ae-9191-8e103a66cc55"); + assert_eq!(value["relay_event"], "connected"); + } + + #[test] + fn stale_atem_cleanup_does_not_remove_replacement() { + let (replacement_tx, _replacement_rx) = mpsc::unbounded_channel::(); + let mut room = PairRoom { + code: "identity-room".to_string(), + hostname: "identity".to_string(), + atem_txs: HashMap::from([( + "atem-office".to_string(), + AtemConnection { + connection_id: "replacement".to_string(), + tx: replacement_tx, + }, + )]), + pending_session_ids: HashMap::from([( + "atem-office".to_string(), + "session-new".to_string(), + )]), + astation_tx: None, + astation_connection_id: None, + created_at: Instant::now(), + }; + + assert!(!room.remove_atem_if_current("atem-office", "stale")); + assert!(room.is_current_atem_connection("atem-office", "replacement")); + assert_eq!( + room.pending_session_ids.get("atem-office").map(String::as_str), + Some("session-new") + ); + assert!(room.remove_atem_if_current("atem-office", "replacement")); + assert!(!room.atem_txs.contains_key("atem-office")); + assert!(!room.pending_session_ids.contains_key("atem-office")); + } + async fn hub_with_room(code: &str) -> RelayHub { let hub = RelayHub::new(); hub.rooms.write().await.insert( @@ -911,6 +1117,7 @@ mod tests { atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now(), }, ); @@ -1025,6 +1232,7 @@ mod tests { atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now(), }; @@ -1049,6 +1257,7 @@ mod tests { atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now() - std::time::Duration::from_secs(ROOM_EXPIRY_SECS + 10), }; hub.rooms @@ -1063,6 +1272,7 @@ mod tests { atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now(), }; hub.rooms @@ -1089,6 +1299,7 @@ mod tests { atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: Some(tx), + astation_connection_id: Some("astation-test".to_string()), created_at: Instant::now() - std::time::Duration::from_secs(ROOM_EXPIRY_SECS + 10), }; hub.rooms @@ -1482,13 +1693,20 @@ mod tests { // Create an old room but with atem connected (not astation) let (tx_atem, _rx) = mpsc::unbounded_channel::(); let mut old_atem_txs = HashMap::new(); - old_atem_txs.insert("test-atem".to_string(), tx_atem); + old_atem_txs.insert( + "test-atem".to_string(), + AtemConnection { + connection_id: "connection-old".to_string(), + tx: tx_atem, + }, + ); let room = PairRoom { code: "OLD-ATEM".to_string(), hostname: "old-host".to_string(), atem_txs: old_atem_txs, pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now() - std::time::Duration::from_secs(ROOM_EXPIRY_SECS + 10), }; hub.rooms.write().await.insert("OLD-ATEM".to_string(), room); @@ -1519,6 +1737,7 @@ mod tests { atem_txs: HashMap::new(), pending_session_ids: HashMap::new(), astation_tx: None, + astation_connection_id: None, created_at: Instant::now(), }; state.relay.rooms.write().await.insert(code.clone(), room); @@ -1551,7 +1770,13 @@ mod tests { let mut rooms = state.relay.rooms.write().await; if let Some(room) = rooms.get_mut(&code) { room.astation_tx = Some(tx_astation); - room.atem_txs.insert("test-atem".to_string(), tx_atem); + room.atem_txs.insert( + "test-atem".to_string(), + AtemConnection { + connection_id: "connection-test".to_string(), + tx: tx_atem, + }, + ); } } From ca77f45964e13e54ef8d5377e6fe0ed8bfaea705 Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 23:29:15 -0700 Subject: [PATCH 09/10] test: cover relay socket replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- .../2026-07-21-device-authentication-v2.md | 6 + relay-server/Cargo.lock | 1 + relay-server/Cargo.toml | 1 + relay-server/src/relay.rs | 142 ++++++++++++++++++ 4 files changed, 150 insertions(+) diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index 23b411e..9a88e5e 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -141,6 +141,12 @@ and covers: - cross-language HMAC test vectors, bounded authentication input, and private file modes. +The relay test suite also starts the real Axum WebSocket router on an ephemeral +loopback port. Its replacement-generation test connects Astation and two Atem +sockets with the same device ID, then verifies that the original socket closes, +stale targeted responses are dropped, the replacement routes messages in both +directions, and its disconnect event carries the active `connection_id`. + Run: ```bash diff --git a/relay-server/Cargo.lock b/relay-server/Cargo.lock index f46f6a5..b1b6efc 100644 --- a/relay-server/Cargo.lock +++ b/relay-server/Cargo.lock @@ -1929,6 +1929,7 @@ dependencies = [ "serde_json", "sqlx", "tokio", + "tokio-tungstenite", "tower", "tower-http", "tower_governor", diff --git a/relay-server/Cargo.toml b/relay-server/Cargo.toml index 7a23c30..e1e5d4c 100644 --- a/relay-server/Cargo.toml +++ b/relay-server/Cargo.toml @@ -24,3 +24,4 @@ async-trait = "0.1" [dev-dependencies] tower = { version = "0.5", features = ["util"] } +tokio-tungstenite = "0.24" diff --git a/relay-server/src/relay.rs b/relay-server/src/relay.rs index 45f75b7..855a6c4 100644 --- a/relay-server/src/relay.rs +++ b/relay-server/src/relay.rs @@ -1014,6 +1014,31 @@ mod tests { use crate::voice_session::VoiceSessionStore; use super::*; use crate::session_verify::SessionVerifyCache; + use tokio::net::TcpStream; + use tokio_tungstenite::{ + tungstenite::Message as ClientMessage, MaybeTlsStream, WebSocketStream, + }; + + type TestSocket = WebSocketStream>; + + async fn next_client_json(socket: &mut TestSocket) -> serde_json::Value { + loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(2), socket.next()) + .await + .expect("timed out waiting for WebSocket message") + .expect("WebSocket closed before receiving message") + .expect("failed to read WebSocket message"); + match frame { + ClientMessage::Text(text) => { + return serde_json::from_str(&text).expect("WebSocket text was not JSON"); + } + ClientMessage::Close(frame) => { + panic!("WebSocket closed unexpectedly: {frame:?}"); + } + _ => {} + } + } + } #[test] fn pairing_code_format() { @@ -1622,6 +1647,123 @@ mod tests { ); } + #[tokio::test] + async fn practical_websocket_replacement_rejects_stale_generation() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test relay"); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, create_relay_app()) + .await + .expect("test relay failed"); + }); + let base_url = format!("ws://{address}/ws"); + let code = "practical-replacement"; + let atem_id = "atem-office"; + + let (mut astation, _) = tokio_tungstenite::connect_async(format!( + "{base_url}?role=astation&code={code}" + )) + .await + .expect("failed to connect Astation"); + let (mut original, _) = tokio_tungstenite::connect_async(format!( + "{base_url}?role=atem&code={code}&atem_id={atem_id}" + )) + .await + .expect("failed to connect original Atem"); + let original_event = next_client_json(&mut astation).await; + assert_eq!(original_event["atem_id"], atem_id); + assert_eq!(original_event["relay_event"], "connected"); + let original_connection_id = original_event["connection_id"] + .as_str() + .expect("connected event lacked connection_id") + .to_string(); + + let (mut replacement, _) = tokio_tungstenite::connect_async(format!( + "{base_url}?role=atem&code={code}&atem_id={atem_id}" + )) + .await + .expect("failed to connect replacement Atem"); + let replacement_event = next_client_json(&mut astation).await; + assert_eq!(replacement_event["atem_id"], atem_id); + assert_eq!(replacement_event["relay_event"], "connected"); + let replacement_connection_id = replacement_event["connection_id"] + .as_str() + .expect("replacement event lacked connection_id") + .to_string(); + assert_ne!(original_connection_id, replacement_connection_id); + + let original_closed = tokio::time::timeout( + std::time::Duration::from_secs(2), + async { + while let Some(frame) = original.next().await { + match frame { + Ok(ClientMessage::Close(_)) | Err(_) => return true, + _ => {} + } + } + true + }, + ) + .await + .unwrap_or(false); + assert!(original_closed, "replaced Atem socket remained open"); + + astation + .send(ClientMessage::Text( + serde_json::json!({ + "atem_id": atem_id, + "connection_id": original_connection_id, + "payload": {"probe": "stale"}, + }) + .to_string(), + )) + .await + .unwrap(); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(150), + replacement.next(), + ) + .await + .is_err(), + "stale targeted response reached replacement Atem" + ); + + astation + .send(ClientMessage::Text( + serde_json::json!({ + "atem_id": atem_id, + "connection_id": replacement_connection_id, + "payload": {"probe": "current"}, + }) + .to_string(), + )) + .await + .unwrap(); + assert_eq!(next_client_json(&mut replacement).await["probe"], "current"); + + replacement + .send(ClientMessage::Text( + serde_json::json!({"probe": "from-atem"}).to_string(), + )) + .await + .unwrap(); + let forwarded = next_client_json(&mut astation).await; + assert_eq!(forwarded["atem_id"], atem_id); + assert_eq!(forwarded["connection_id"], replacement_connection_id); + assert_eq!(forwarded["payload"]["probe"], "from-atem"); + + replacement.close(None).await.unwrap(); + let disconnect_event = next_client_json(&mut astation).await; + assert_eq!(disconnect_event["atem_id"], atem_id); + assert_eq!(disconnect_event["connection_id"], replacement_connection_id); + assert_eq!(disconnect_event["relay_event"], "disconnected"); + + server.abort(); + } + #[tokio::test] async fn test_create_multiple_pairs_unique_codes() { let app = create_relay_app(); From e08464ac24207523d9d82708496756db9b9eb3b1 Mon Sep 17 00:00:00 2001 From: Brent G Date: Tue, 21 Jul 2026 23:36:38 -0700 Subject: [PATCH 10/10] fix: secure stored sessions before loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- Sources/Menubar/SessionStore.swift | 23 ++++++++- .../DeviceAuthenticationTests.swift | 47 +++++++++++++++++++ .../2026-07-21-device-authentication-v2.md | 2 + 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/Sources/Menubar/SessionStore.swift b/Sources/Menubar/SessionStore.swift index 5739b9e..9ec3f34 100644 --- a/Sources/Menubar/SessionStore.swift +++ b/Sources/Menubar/SessionStore.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation /// Session information for a paired Atem device. @@ -230,12 +231,32 @@ class SessionStore { private func loadFromDisk() { // Must be called from queue with barrier - guard FileManager.default.fileExists(atPath: storePath.path) else { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: storePath.path) || + (try? fileManager.destinationOfSymbolicLink(atPath: storePath.path)) != nil else { Log.debug("No existing sessions file found") return } do { + let values = try storePath.resourceValues(forKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey + ]) + let attributes = try fileManager.attributesOfItem(atPath: storePath.path) + let owner = (attributes[.ownerAccountID] as? NSNumber)?.uint32Value + guard values.isRegularFile == true, + values.isSymbolicLink != true, + owner == getuid() else { + Log.error("Refusing to load insecure sessions file at \(storePath.path)") + return + } + + // Close the migration window before bearer tokens are read into memory. + try fileManager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: storePath.path + ) let data = try Data(contentsOf: storePath) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 diff --git a/Tests/AstationTests/DeviceAuthenticationTests.swift b/Tests/AstationTests/DeviceAuthenticationTests.swift index 0a1e2b4..78b9949 100644 --- a/Tests/AstationTests/DeviceAuthenticationTests.swift +++ b/Tests/AstationTests/DeviceAuthenticationTests.swift @@ -227,6 +227,53 @@ final class DeviceAuthenticationTests: XCTestCase { )) } + func testSessionStoreSecuresExistingFileBeforeLoading() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AstationSessionPermissionTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let sessionsURL = directory.appendingPathComponent("sessions.json") + let session = SessionStore(storageURL: sessionsURL).create( + hostname: "office", + atemId: "atem-office" + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: sessionsURL.path + ) + + let reopened = SessionStore(storageURL: sessionsURL) + let mode = try FileManager.default.attributesOfItem(atPath: sessionsURL.path)[.posixPermissions] + as? NSNumber + XCTAssertEqual(reopened.get(sessionId: session.id)?.atemId, "atem-office") + XCTAssertEqual(mode?.intValue, 0o600) + } + + func testSessionStoreRefusesSymbolicLinkWithoutChangingTarget() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AstationSessionSymlinkTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let targetURL = root.appendingPathComponent("target.json") + let session = SessionStore(storageURL: targetURL).create( + hostname: "office", + atemId: "atem-office" + ) + let originalData = try Data(contentsOf: targetURL) + let linkURL = root.appendingPathComponent("sessions.json") + try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: targetURL) + + let linkedStore = SessionStore(storageURL: linkURL) + XCTAssertNil(linkedStore.get(sessionId: session.id)) + XCTAssertEqual(try Data(contentsOf: targetURL), originalData) + XCTAssertEqual( + try linkURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink, + true + ) + } + func testLegacySessionBindsToFirstDeviceWithValidProof() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("AstationLegacySessionTests-\(UUID().uuidString)") diff --git a/docs/specs/2026-07-21-device-authentication-v2.md b/docs/specs/2026-07-21-device-authentication-v2.md index 9a88e5e..c7c6f75 100644 --- a/docs/specs/2026-07-21-device-authentication-v2.md +++ b/docs/specs/2026-07-21-device-authentication-v2.md @@ -76,6 +76,8 @@ Empty values and control characters are rejected. Relay challenges expire after two minutes and no more than 64 may be pending at once. Astation rotates the same-user bootstrap token instead of trusting an existing file with loose permissions, the wrong owner, or a symbolic-link path. +Before loading existing device sessions, Astation verifies that `sessions.json` +is a current-user regular file, refuses symbolic links, and applies mode `0600`. ## Local state