From 8fe93d165208e664ef7ab557d87c979308fccf6b Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 13:26:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20appareils=20appair=C3=A9s=20pe?= =?UTF-8?q?rsistants,=20r=C3=A9vocables=20et=20code=20=C3=A9ph=C3=A9m?= =?UTF-8?q?=C3=A8re=20(#77=20B1-B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'appairage ne survivait pas au redémarrage et son code, permanent, était imprimé sur la sortie standard. Un appareil appairé devient une entité persistante, nommée et révocable, derrière un code désormais éphémère. - B1 : port DeviceSessionStore et adapter FsDeviceSessionStore, entités de domaine (PairedDevice, DeviceId, SessionTokenHash, DeviceName). Les tokens sont hachés en SHA-256 et comparés en temps constant (subtle) : le store ne peut pas rejouer une session qu'il a servie. Cookie Max-Age 400 j à renouvellement glissant, lastSeenAtMs throttlé. - B2 : code éphémère en mémoire, TTL 10 min et usage unique, toute génération invalidant la précédente. POST /api/pairing-code authentifiée, flag --new-code. Le code est retiré du boot et l'eprintln! qui l'imprimait est supprimé. - B3 : endpoints devices (list/rename/revoke/revoke-all/logout), event DeviceRevoked et ActiveConnectionRegistry par device_id, fermant sans délai les WebSockets d'un appareil révoqué. - B4 : port PairAttemptLimiter et adapter mémoire, rate-limit par origine et global sur horloge injectée, donc testable sans attente réelle. La normalisation du code passe côté serveur : elle absorbe la dette #76, que la seule normalisation frontend de #75 ne faisait que masquer. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 8 + Cargo.toml | 4 + crates/app-tauri/src/commands.rs | 160 +- crates/app-tauri/src/embedded_server.rs | 30 +- crates/app-tauri/src/lib.rs | 6 + crates/application/Cargo.toml | 1 + crates/application/src/device.rs | 482 ++++++ crates/application/src/lib.rs | 8 + crates/backend/src/events.rs | 12 + crates/backend/src/lib.rs | 140 +- crates/domain/Cargo.toml | 3 + crates/domain/src/device.rs | 184 +++ crates/domain/src/events.rs | 8 + crates/domain/src/lib.rs | 5 + crates/domain/src/ports.rs | 40 + crates/infrastructure/src/lib.rs | 10 +- .../src/pair_attempt_limiter.rs | 153 ++ .../src/store/device_session.rs | 176 +++ crates/infrastructure/src/store/mod.rs | 2 + crates/web-server/Cargo.toml | 4 + crates/web-server/src/lib.rs | 1352 +++++++++++++++-- 21 files changed, 2603 insertions(+), 185 deletions(-) create mode 100644 crates/application/src/device.rs create mode 100644 crates/domain/src/device.rs create mode 100644 crates/infrastructure/src/pair_attempt_limiter.rs create mode 100644 crates/infrastructure/src/store/device_session.rs diff --git a/Cargo.lock b/Cargo.lock index f66cdb9..953cadd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,7 @@ dependencies = [ "domain", "serde", "serde_json", + "subtle", "thiserror 2.0.18", "tokio", "uuid", @@ -905,8 +906,11 @@ name = "domain" version = "0.3.0" dependencies = [ "async-trait", + "hex", "serde", "serde_json", + "sha2", + "subtle", "thiserror 2.0.18", "tokio", "uuid", @@ -5247,10 +5251,14 @@ dependencies = [ "bytes", "cookie", "domain", + "getrandom 0.3.4", + "hex", "http", "http-body-util", "serde", "serde_json", + "sha2", + "subtle", "tokio", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index a1fe65e..a497c5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ thiserror = "2" async-trait = "0.1" futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] } +hex = "0.4" +sha2 = "0.10" +subtle = "2" +getrandom = "0.3" # Local git via libgit2. Network features (https/ssh → openssl) are off for L8: # only local operations (status/commit/branch/checkout/log) are in scope; remote # push/pull and static vendoring for the AppImage are deferred to L9/L11. diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 4ac33ad..9628a12 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -16,12 +16,12 @@ use application::{ DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, - LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput, + LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, - ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput, - ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput, + ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, + ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, @@ -66,7 +66,8 @@ use crate::embedded_server::{ }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::{AppState, FocusedProjectDto}; -use domain::{SkillRef, SkillScope}; +use domain::{DeviceId, SkillRef, SkillScope}; +use uuid::Uuid; /// `health` — trivial command validating the full IPC pipeline /// (frontend gateway → invoke → command → use case → ports → event relay). @@ -149,6 +150,157 @@ pub async fn embedded_server_stop( state.embedded_server.stop().await } +/// `embedded_server_generate_pairing_code` — generate a desktop-owned ephemeral code. +/// +/// # Errors +/// Returns an [`ErrorDto`] when the embedded server is not running. +#[tauri::command] +pub fn embedded_server_generate_pairing_code( + state: State<'_, AppState>, +) -> Result { + state.embedded_server.generate_pairing_code() +} + +/// Device row exposed to the desktop settings surface. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceDto { + /// Device id. + pub device_id: String, + /// User-facing name. + pub name: String, + /// Pairing timestamp as epoch milliseconds. + pub paired_at_ms: u64, + /// Last successful access timestamp as epoch milliseconds. + pub last_seen_at_ms: u64, + /// Whether this is the current authenticated web device. + pub is_current_device: bool, +} + +/// List response for paired devices. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceListDto { + /// Paired devices. + pub devices: Vec, +} + +/// Rename request for paired devices. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameDeviceRequestDto { + /// Device id. + pub device_id: String, + /// New name. + pub name: String, +} + +/// Revoke request for one paired device. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevokeDeviceRequestDto { + /// Device id. + pub device_id: String, +} + +fn parse_device_id(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(DeviceId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: "invalid device id".to_owned(), + }) +} + +/// `list_devices` — list paired devices through the desktop composition root. +/// +/// # Errors +/// Returns an [`ErrorDto`] when the device store cannot be read. +#[tauri::command] +pub async fn list_devices(state: State<'_, AppState>) -> Result { + let output = state + .list_devices + .execute(ListDevicesInput { + current_device_id: None, + }) + .await + .map_err(ErrorDto::from)?; + Ok(DeviceListDto { + devices: output + .devices + .into_iter() + .map(|device| DeviceDto { + device_id: device.device_id.to_string(), + name: device.name, + paired_at_ms: device.paired_at_ms, + last_seen_at_ms: device.last_seen_at_ms, + is_current_device: device.is_current_device, + }) + .collect(), + }) +} + +/// `create_pairing_code` — generate an ephemeral pairing code via the embedded server state. +/// +/// # Errors +/// Returns an [`ErrorDto`] when the embedded server is not running. +#[tauri::command] +pub fn create_pairing_code( + state: State<'_, AppState>, +) -> Result { + state.embedded_server.generate_pairing_code() +} + +/// `rename_device` — rename a paired device. +/// +/// # Errors +/// Returns an [`ErrorDto`] for invalid input or store failures. +#[tauri::command] +pub async fn rename_device( + request: RenameDeviceRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .rename_device + .execute(RenameDeviceInput { + device_id: parse_device_id(&request.device_id)?, + name: request.name, + }) + .await + .map_err(ErrorDto::from) +} + +/// `revoke_device` — revoke one paired device. +/// +/// # Errors +/// Returns an [`ErrorDto`] for invalid input or store failures. +#[tauri::command] +pub async fn revoke_device( + request: RevokeDeviceRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .revoke_device + .execute(RevokeDeviceInput { + device_id: parse_device_id(&request.device_id)?, + }) + .await + .map_err(ErrorDto::from) +} + +/// `revoke_all_devices` — revoke every paired device. +/// +/// # Errors +/// Returns an [`ErrorDto`] when the device store cannot be rewritten. +#[tauri::command] +pub async fn revoke_all_devices(state: State<'_, AppState>) -> Result<(), ErrorDto> { + state + .revoke_all_devices + .execute() + .await + .map_err(ErrorDto::from) +} + /// `create_project` — create a project from a root: init `.ideai/`, register it. /// /// # Errors diff --git a/crates/app-tauri/src/embedded_server.rs b/crates/app-tauri/src/embedded_server.rs index 0684543..4a4a76b 100644 --- a/crates/app-tauri/src/embedded_server.rs +++ b/crates/app-tauri/src/embedded_server.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; use backend::BackendCore; use serde::{Deserialize, Serialize}; -use web_server::{EmbeddedServerHandle, ServerConfig, TrustedProxy}; +use web_server::{EmbeddedServerHandle, PairingCodeDto, ServerConfig, TrustedProxy}; use crate::dto::ErrorDto; @@ -93,8 +93,6 @@ pub struct EmbeddedServerStatusDto { pub public_url: Option, /// Reverse-proxy upstream URL derived from settings. pub upstream_url: Option, - /// Runtime pairing code, never persisted. - pub pairing_code: Option, /// Last failure, when state is `failed`. pub error: Option, } @@ -278,6 +276,21 @@ impl EmbeddedServerController { } } + /// Generates a new ephemeral pairing code on the running embedded server. + /// + /// # Errors + /// Returns an [`ErrorDto`] if the embedded server is not running. + pub fn generate_pairing_code(&self) -> Result { + let inner = self.inner.lock().expect("embedded server mutex poisoned"); + let Some(handle) = inner.handle.as_ref() else { + return Err(ErrorDto { + code: "UNAVAILABLE".to_owned(), + message: "embedded server is not running".to_owned(), + }); + }; + Ok(handle.generate_pairing_code()) + } + /// Stops the embedded server. Idempotent when already stopped. /// /// # Errors @@ -331,10 +344,6 @@ fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto { local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()), public_url: inner.public_url.clone(), upstream_url: inner.upstream_url.clone(), - pairing_code: inner - .handle - .as_ref() - .map(|handle| handle.pairing_code().to_owned()), error, } } @@ -436,6 +445,7 @@ fn server_config_from_settings( trusted_proxies, app_data_dir, web_root, + new_code: false, }; config.validate().map_err(invalid_error)?; Ok(config) @@ -771,7 +781,9 @@ mod tests { .as_deref() .is_some_and(|url| url.starts_with("http://127.0.0.1:"))); assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0")); - assert_eq!(first.pairing_code, second.pairing_code); + let pairing = controller.generate_pairing_code().unwrap(); + assert_eq!(pairing.ttl_seconds, 600); + assert_eq!(pairing.code.len(), 8); let stopped = controller.stop().await.unwrap(); @@ -780,7 +792,6 @@ mod tests { EmbeddedServerStatusStateDto::Stopped )); assert!(stopped.local_url.is_none()); - assert!(stopped.pairing_code.is_none()); } #[tokio::test] @@ -804,6 +815,5 @@ mod tests { assert_eq!(err.code, "INVALID"); assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed)); - assert!(status.pairing_code.is_none()); } } diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index d1b160b..ba7e0f4 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -292,6 +292,12 @@ pub fn run() { commands::embedded_server_status, commands::embedded_server_start, commands::embedded_server_stop, + commands::embedded_server_generate_pairing_code, + commands::list_devices, + commands::create_pairing_code, + commands::rename_device, + commands::revoke_device, + commands::revoke_all_devices, ]) .run(tauri::generate_context!()) .expect("error while running IdeA Tauri application"); diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 13a5e25..d6f7e27 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -12,6 +12,7 @@ thiserror = { workspace = true } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +subtle = { workspace = true } # `v5` derives stable reference-profile ids from a fixed namespace (catalogue). uuid = { workspace = true } # `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4). diff --git a/crates/application/src/device.rs b/crates/application/src/device.rs new file mode 100644 index 0000000..269dca1 --- /dev/null +++ b/crates/application/src/device.rs @@ -0,0 +1,482 @@ +//! Paired-device session use cases. + +use std::sync::Arc; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::ports::{Clock, DeviceSessionStore, EventBus, IdGenerator, StoreError}; +use domain::{AuthenticatedDevice, DeviceId, DeviceName, PairedDevice, SessionTokenHash}; +use subtle::ConstantTimeEq; + +use crate::error::AppError; + +const LAST_SEEN_TOUCH_THROTTLE_MS: u64 = 5 * 60 * 1000; + +/// Input for [`PairDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairDeviceInput { + /// Name supplied by the new device. + pub name: String, + /// Hash of the freshly issued session token. + pub session_token_hash: SessionTokenHash, +} + +/// Output for [`PairDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairDeviceOutput { + /// Created paired device. + pub device: PairedDevice, +} + +/// Validates a pairing request and persists the new paired device. +pub struct PairDevice { + store: Arc, + ids: Arc, + clock: Arc, +} + +impl PairDevice { + /// Builds the use case from injected ports. + #[must_use] + pub fn new( + store: Arc, + ids: Arc, + clock: Arc, + ) -> Self { + Self { store, ids, clock } + } + + /// Executes device pairing. + /// + /// # Errors + /// Returns [`AppError::Invalid`] for an invalid name and [`AppError::Store`] for + /// persistence failures. + pub async fn execute(&self, input: PairDeviceInput) -> Result { + let now = self.clock.now_millis().max(0) as u64; + let device = PairedDevice { + device_id: DeviceId::from_uuid(self.ids.new_uuid()), + name: DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?, + paired_at_ms: now, + last_seen_at_ms: now, + session_token_hash: input.session_token_hash, + }; + let mut devices = self.store.load_devices().await?; + devices.push(device.clone()); + self.store.save_devices(&devices).await?; + Ok(PairDeviceOutput { device }) + } +} + +/// Rate-limit key for pairing attempts. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RateLimitKey { + /// Resolved origin identity, typically the peer IP after transport-layer proxy handling. + pub origin: String, + /// Logical route being limited. + pub route: String, +} + +/// Pairing-attempt limiter port. +#[async_trait] +pub trait PairAttemptLimiter: Send + Sync { + /// Returns whether a new failed pairing attempt may be processed. + async fn check(&self, key: RateLimitKey) -> Result; + + /// Records one failed pairing attempt for the key and the global bucket. + async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError>; +} + +/// Result of a rate-limit check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PairAttemptDecision { + /// Attempt can proceed. + Allowed, + /// Attempt is blocked by one of the buckets. + RateLimited, +} + +/// Input for [`AuthenticateSession`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticateSessionInput { + /// Raw token bytes decoded from the session cookie. + pub token_bytes: Vec, +} + +/// Authenticates a persisted device session. +pub struct AuthenticateSession { + store: Arc, +} + +impl AuthenticateSession { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Returns the authenticated device, including its token hash, when valid. + /// + /// # Errors + /// Returns [`AppError::Store`] for persistence failures. + pub async fn execute( + &self, + input: AuthenticateSessionInput, + ) -> Result, AppError> { + Ok(self.store.authenticate_token(&input.token_bytes).await?) + } +} + +/// Input for [`TouchDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TouchDeviceInput { + /// Device to mark as seen. + pub device_id: DeviceId, +} + +/// Output for [`TouchDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TouchDeviceOutput { + /// Whether the store was rewritten. + pub updated: bool, +} + +/// Device row exposed to presentation layers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceView { + /// Persistent device id. + pub device_id: DeviceId, + /// User-facing name. + pub name: String, + /// Pairing timestamp as epoch milliseconds. + pub paired_at_ms: u64, + /// Last successful authenticated access timestamp as epoch milliseconds. + pub last_seen_at_ms: u64, + /// Whether this row is the authenticated current device. + pub is_current_device: bool, +} + +/// Input for [`ListDevices`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListDevicesInput { + /// Current device id, when the driving adapter has an authenticated device. + pub current_device_id: Option, +} + +/// Output for [`ListDevices`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListDevicesOutput { + /// Paired devices. + pub devices: Vec, +} + +/// Lists paired devices without exposing transport-sensitive fields. +pub struct ListDevices { + store: Arc, +} + +impl ListDevices { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the listing. + /// + /// # Errors + /// Returns [`AppError::Store`] for persistence failures. + pub async fn execute(&self, input: ListDevicesInput) -> Result { + let devices = self + .store + .load_devices() + .await? + .into_iter() + .map(|device| DeviceView { + device_id: device.device_id, + name: device.name.as_str().to_owned(), + paired_at_ms: device.paired_at_ms, + last_seen_at_ms: device.last_seen_at_ms, + is_current_device: Some(device.device_id) == input.current_device_id, + }) + .collect(); + Ok(ListDevicesOutput { devices }) + } +} + +/// Input for [`RenameDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenameDeviceInput { + /// Device to rename. + pub device_id: DeviceId, + /// New user-facing name. + pub name: String, +} + +/// Renames a paired device. +pub struct RenameDevice { + store: Arc, +} + +impl RenameDevice { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the rename. + /// + /// # Errors + /// Returns [`AppError::Invalid`] for an invalid name, [`AppError::NotFound`] + /// when the device does not exist, and [`AppError::Store`] for persistence failures. + pub async fn execute(&self, input: RenameDeviceInput) -> Result<(), AppError> { + let new_name = + DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?; + let mut devices = self.store.load_devices().await?; + let Some(device) = devices + .iter_mut() + .find(|device| device.device_id == input.device_id) + else { + return Err(AppError::NotFound("device not found".to_owned())); + }; + device.name = new_name; + self.store.save_devices(&devices).await?; + Ok(()) + } +} + +/// Input for [`RevokeDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RevokeDeviceInput { + /// Device to revoke. + pub device_id: DeviceId, +} + +/// Revokes one paired device and publishes a revocation event after persistence. +pub struct RevokeDevice { + store: Arc, + events: Arc, +} + +impl RevokeDevice { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc, events: Arc) -> Self { + Self { store, events } + } + + /// Executes the revocation. + /// + /// # Errors + /// Returns [`AppError::NotFound`] when the device does not exist and + /// [`AppError::Store`] for persistence failures. + pub async fn execute(&self, input: RevokeDeviceInput) -> Result<(), AppError> { + if !self.store.remove_device(input.device_id).await? { + return Err(AppError::NotFound("device not found".to_owned())); + } + self.events.publish(DomainEvent::DeviceRevoked { + device_id: input.device_id, + }); + Ok(()) + } +} + +/// Revokes every paired device and publishes a global revocation event. +pub struct RevokeAllDevices { + store: Arc, + events: Arc, +} + +impl RevokeAllDevices { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc, events: Arc) -> Self { + Self { store, events } + } + + /// Executes global revocation. + /// + /// # Errors + /// Returns [`AppError::Store`] for persistence failures. + pub async fn execute(&self) -> Result<(), AppError> { + self.store.save_devices(&[]).await?; + self.events.publish(DomainEvent::AllDevicesRevoked); + Ok(()) + } +} + +/// Updates `lastSeenAtMs`, throttled to avoid rewriting the JSON on every request. +pub struct TouchDevice { + store: Arc, + clock: Arc, +} + +impl TouchDevice { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc, clock: Arc) -> Self { + Self { store, clock } + } + + /// Executes the touch. + /// + /// # Errors + /// Returns [`AppError::Store`] for persistence failures. + pub async fn execute(&self, input: TouchDeviceInput) -> Result { + let now = self.clock.now_millis().max(0) as u64; + let mut devices = self.store.load_devices().await?; + let Some(device) = devices + .iter_mut() + .find(|device| device.device_id == input.device_id) + else { + return Ok(TouchDeviceOutput { updated: false }); + }; + + if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS { + return Ok(TouchDeviceOutput { updated: false }); + } + + device.last_seen_at_ms = now; + self.store.save_devices(&devices).await?; + Ok(TouchDeviceOutput { updated: true }) + } +} + +/// Normalizes user-entered pairing codes server-side. +#[must_use] +pub fn normalize_pairing_code(code: &str) -> String { + code.chars() + .filter(|ch| !ch.is_whitespace() && *ch != '-') + .flat_map(char::to_uppercase) + .collect() +} + +/// Compares pairing codes after normalization without early-exit byte comparison. +#[must_use] +pub fn pairing_codes_equal(candidate: &str, expected: &str) -> bool { + let candidate = normalize_pairing_code(candidate); + let expected = normalize_pairing_code(expected); + let bytes_equal: bool = candidate.as_bytes().ct_eq(expected.as_bytes()).into(); + bytes_equal && candidate.len() == expected.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use domain::ports::StoreError; + use std::sync::Mutex; + use uuid::Uuid; + + #[derive(Default)] + struct FakeStore { + devices: Mutex>, + saves: Mutex, + } + + #[async_trait] + impl DeviceSessionStore for FakeStore { + async fn load_devices(&self) -> Result, StoreError> { + Ok(self.devices.lock().unwrap().clone()) + } + + async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> { + *self.devices.lock().unwrap() = devices.to_vec(); + *self.saves.lock().unwrap() += 1; + Ok(()) + } + } + + struct FixedClock(i64); + + impl domain::ports::Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } + } + + struct FixedIds; + + impl domain::ports::IdGenerator for FixedIds { + fn new_uuid(&self) -> Uuid { + Uuid::from_u128(7) + } + } + + #[test] + fn pairing_code_normalization_removes_spaces_hyphens_and_uppercases() { + assert_eq!(normalize_pairing_code(" ab12-cd34 "), "AB12CD34"); + assert!(pairing_codes_equal(" ab12-cd34 ", "AB12CD34")); + assert!(!pairing_codes_equal("AB12CD35", "AB12CD34")); + } + + #[tokio::test] + async fn pair_device_validates_name_and_persists_device() { + let store = Arc::new(FakeStore::default()); + let use_case = PairDevice::new( + Arc::clone(&store) as Arc, + Arc::new(FixedIds), + Arc::new(FixedClock(1234)), + ); + + let output = use_case + .execute(PairDeviceInput { + name: "Phone".to_owned(), + session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), + }) + .await + .unwrap(); + + assert_eq!(output.device.name.as_str(), "Phone"); + assert_eq!(store.load_devices().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn pair_device_rejects_empty_name() { + let use_case = PairDevice::new( + Arc::new(FakeStore::default()), + Arc::new(FixedIds), + Arc::new(FixedClock(1234)), + ); + + let err = use_case + .execute(PairDeviceInput { + name: " ".to_owned(), + session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + } + + #[tokio::test] + async fn touch_device_is_throttled_to_five_minutes() { + let device = PairedDevice { + device_id: DeviceId::from_uuid(Uuid::from_u128(7)), + name: DeviceName::new("Phone").unwrap(), + paired_at_ms: 1000, + last_seen_at_ms: 1000, + session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), + }; + let store = Arc::new(FakeStore::default()); + store.save_devices(&[device]).await.unwrap(); + *store.saves.lock().unwrap() = 0; + let use_case = TouchDevice::new( + Arc::clone(&store) as Arc, + Arc::new(FixedClock( + (1000 + LAST_SEEN_TOUCH_THROTTLE_MS - 1) as i64, + )), + ); + + let output = use_case + .execute(TouchDeviceInput { + device_id: DeviceId::from_uuid(Uuid::from_u128(7)), + }) + .await + .unwrap(); + + assert!(!output.updated); + assert_eq!(*store.saves.lock().unwrap(), 0); + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 7d069de..0105aed 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -14,6 +14,7 @@ pub mod agent; pub mod background; pub mod conversation; +pub mod device; pub mod diag; pub mod embedder; pub mod error; @@ -65,6 +66,13 @@ pub use conversation::{ ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn, RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView, }; +pub use device::{ + normalize_pairing_code, pairing_codes_equal, AuthenticateSession, AuthenticateSessionInput, + DeviceView, ListDevices, ListDevicesInput, ListDevicesOutput, PairAttemptDecision, + PairAttemptLimiter, PairDevice, PairDeviceInput, PairDeviceOutput, RateLimitKey, RenameDevice, + RenameDeviceInput, RevokeAllDevices, RevokeDevice, RevokeDeviceInput, TouchDevice, + TouchDeviceInput, TouchDeviceOutput, +}; pub use embedder::{ CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput, DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice, diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index ef1805c..7ad3b24 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -302,6 +302,14 @@ pub enum DomainEventDto { /// Version synced to. to: u64, }, + /// A paired device was revoked. + #[serde(rename_all = "camelCase")] + DeviceRevoked { + /// Device id. + device_id: String, + }, + /// Every paired device was revoked. + AllDevicesRevoked, /// A skill was assigned to (or unassigned from) an agent. #[serde(rename_all = "camelCase")] SkillAssigned { @@ -884,6 +892,10 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), to: to.get(), }, + DomainEvent::DeviceRevoked { device_id } => Self::DeviceRevoked { + device_id: device_id.to_string(), + }, + DomainEvent::AllDevicesRevoked => Self::AllDevicesRevoked, DomainEvent::SkillAssigned { agent_id, skill_id, diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index e06c9a0..2ee5bcc 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -13,43 +13,46 @@ use std::sync::{Arc, Mutex}; use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, - AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, - ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject, - CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, - CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, - CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, - DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, - DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, - DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, - GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, - GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, - InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, - ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, - ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, - LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, - McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, - ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, + CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, + CloneOpenCodeProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, + ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, + CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, + CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, + DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, + DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, + EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, + GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, + GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, + LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, + ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, + MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, + ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, + ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog, - SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, - SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, - StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, - TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, - UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, - UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice, + RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, + SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, + UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, + WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, - BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, - EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, - IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, - ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, + BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, + EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, + IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, + ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore, }; @@ -69,14 +72,15 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, - FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, - FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, - FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, - FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, - FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, - HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, - MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, + FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore, + FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, + FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, + FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore, + FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, + HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, + LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, + NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, @@ -798,6 +802,24 @@ impl AgentResumer for AppAgentResumer { pub struct BackendCore { /// Trivial health use case validating the end-to-end wiring. pub health: Arc, + /// Pair a persistent device session. + pub pair_device: Arc, + /// Limits failed pairing attempts. + pub pair_attempt_limiter: Arc, + /// Authenticate a persistent device session. + pub authenticate_session: Arc, + /// Throttled update of the authenticated device last-seen timestamp. + pub touch_device: Arc, + /// List paired devices. + pub list_devices: Arc, + /// Rename a paired device. + pub rename_device: Arc, + /// Revoke one paired device. + pub revoke_device: Arc, + /// Revoke every paired device. + pub revoke_all_devices: Arc, + /// Paired-device store port, exposed for legacy logout revocation. + pub device_session_store: Arc, /// Create a project (init `.ideai/`, register it). pub create_project: Arc, /// Open a project (load meta + manifest). @@ -1134,11 +1156,21 @@ impl BackendCore { Arc::clone(&fs) as Arc, app_data_dir.to_string_lossy().into_owned(), )); + let device_session_store = Arc::new(FsDeviceSessionStore::new( + Arc::clone(&fs) as Arc, + app_data_dir.to_string_lossy().into_owned(), + )); + let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new( + Arc::clone(&clock) as Arc + )); // Port-typed handles for injection. let fs_port = Arc::clone(&fs) as Arc; let store_port = Arc::clone(&store) as Arc; let window_state_port = Arc::clone(&window_state_store) as Arc; + let device_session_port = Arc::clone(&device_session_store) as Arc; + let pair_attempt_limiter_port = + Arc::clone(&pair_attempt_limiter) as Arc; let events_port = Arc::clone(&event_bus) as Arc; // --- Use cases (ports injected as Arc) --- @@ -1147,6 +1179,27 @@ impl BackendCore { Arc::clone(&ids) as Arc, Arc::clone(&events_port), )); + let pair_device = Arc::new(PairDevice::new( + Arc::clone(&device_session_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + )); + let authenticate_session = + Arc::new(AuthenticateSession::new(Arc::clone(&device_session_port))); + let touch_device = Arc::new(TouchDevice::new( + Arc::clone(&device_session_port), + Arc::clone(&clock) as Arc, + )); + let list_devices = Arc::new(ListDevices::new(Arc::clone(&device_session_port))); + let rename_device = Arc::new(RenameDevice::new(Arc::clone(&device_session_port))); + let revoke_device = Arc::new(RevokeDevice::new( + Arc::clone(&device_session_port), + Arc::clone(&events_port), + )); + let revoke_all_devices = Arc::new(RevokeAllDevices::new( + Arc::clone(&device_session_port), + Arc::clone(&events_port), + )); let create_project = Arc::new(CreateProject::new( Arc::clone(&store_port), @@ -2316,6 +2369,15 @@ impl BackendCore { Self { health, + pair_device, + pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port), + authenticate_session, + touch_device, + list_devices, + rename_device, + revoke_device, + revoke_all_devices, + device_session_store: Arc::clone(&device_session_port), create_project, open_project, close_project, diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 75ad042..4748cbb 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -12,6 +12,9 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } +subtle = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/domain/src/device.rs b/crates/domain/src/device.rs new file mode 100644 index 0000000..e86f1f3 --- /dev/null +++ b/crates/domain/src/device.rs @@ -0,0 +1,184 @@ +//! Persistent paired-device access model. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use thiserror::Error; +use uuid::Uuid; + +const SESSION_TOKEN_HASH_PREFIX: &str = "sha256:"; +const SESSION_TOKEN_HASH_CONTEXT: &[u8] = b"idea-session-v1\0"; +const SESSION_TOKEN_HASH_HEX_LEN: usize = 64; + +/// Unique identifier for a paired device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DeviceId(Uuid); + +impl DeviceId { + /// Builds a device id from an existing UUID. + #[must_use] + pub const fn from_uuid(value: Uuid) -> Self { + Self(value) + } + + /// Returns the wrapped UUID. + #[must_use] + pub const fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl std::fmt::Display for DeviceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// Device display name supplied by the newly paired device. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DeviceName(String); + +impl DeviceName { + /// Validates and stores a device name. + /// + /// Names are required and limited to 40 Unicode scalar values. + pub fn new(value: impl Into) -> Result { + let value = value.into().trim().to_owned(); + let len = value.chars().count(); + if len == 0 { + return Err(DeviceError::InvalidName( + "device name is required".to_owned(), + )); + } + if len > 40 { + return Err(DeviceError::InvalidName( + "device name must be 40 characters or fewer".to_owned(), + )); + } + Ok(Self(value)) + } + + /// Returns the stored name. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Hash of a random 256-bit session token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SessionTokenHash(String); + +impl SessionTokenHash { + /// Hashes raw token bytes as `SHA-256("idea-session-v1\0" || token_bytes)`. + #[must_use] + pub fn from_token_bytes(token_bytes: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(SESSION_TOKEN_HASH_CONTEXT); + hasher.update(token_bytes); + let digest = hasher.finalize(); + Self(format!( + "{SESSION_TOKEN_HASH_PREFIX}{}", + hex::encode(digest) + )) + } + + /// Validates an already persisted hash string. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_hash_string(&value)?; + Ok(Self(value)) + } + + /// Returns the persisted hash string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Constant-time verification of raw token bytes against this stored hash. + #[must_use] + pub fn verify_token_bytes(&self, token_bytes: &[u8]) -> bool { + let candidate = Self::from_token_bytes(token_bytes); + self.constant_time_eq(&candidate) + } + + /// Constant-time equality for two valid persisted token hashes. + #[must_use] + pub fn constant_time_eq(&self, other: &Self) -> bool { + self.0.as_bytes().ct_eq(other.0.as_bytes()).into() + } +} + +/// A paired device persisted in the app-data security store. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairedDevice { + /// Persistent device id. + pub device_id: DeviceId, + /// User-facing device name. + pub name: DeviceName, + /// Pairing timestamp as epoch milliseconds. + pub paired_at_ms: u64, + /// Last successful authenticated access timestamp as epoch milliseconds. + pub last_seen_at_ms: u64, + /// Hash of the bearer session token. + pub session_token_hash: SessionTokenHash, +} + +/// Successful session authentication result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticatedDevice { + /// Authenticated device id. + pub device_id: DeviceId, + /// Matched token hash. + pub token_hash: SessionTokenHash, +} + +/// Device-domain validation errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum DeviceError { + /// Invalid device name. + #[error("invalid device name: {0}")] + InvalidName(String), + /// Invalid persisted token hash. + #[error("invalid session token hash")] + InvalidSessionTokenHash, +} + +fn validate_hash_string(value: &str) -> Result<(), DeviceError> { + let Some(hex) = value.strip_prefix(SESSION_TOKEN_HASH_PREFIX) else { + return Err(DeviceError::InvalidSessionTokenHash); + }; + if hex.len() != SESSION_TOKEN_HASH_HEX_LEN || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(DeviceError::InvalidSessionTokenHash); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_token_hash_is_prefixed_sha256_and_verifies_constant_time() { + let token = [7_u8; 32]; + let hash = SessionTokenHash::from_token_bytes(&token); + + assert!(hash.as_str().starts_with("sha256:")); + assert_eq!(hash.as_str().len(), "sha256:".len() + 64); + assert!(hash.verify_token_bytes(&token)); + assert!(!hash.verify_token_bytes(&[8_u8; 32])); + assert!(hash.constant_time_eq(&SessionTokenHash::new(hash.as_str()).unwrap())); + } + + #[test] + fn device_name_is_required_and_limited() { + assert!(DeviceName::new("phone").is_ok()); + assert!(DeviceName::new(" ").is_err()); + assert!(DeviceName::new("a".repeat(41)).is_err()); + } +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 107014e..d096926 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -2,6 +2,7 @@ //! presentation layer (ARCHITECTURE §3.2). use crate::conversation::ConversationParty; +use crate::device::DeviceId; use crate::ids::{ AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId, @@ -268,6 +269,13 @@ pub enum DomainEvent { /// Version it was brought up to. to: TemplateVersion, }, + /// A paired device was revoked and any live transport for it must be closed. + DeviceRevoked { + /// Revoked device. + device_id: DeviceId, + }, + /// All paired devices were revoked and every live paired transport must close. + AllDevicesRevoked, /// A skill was assigned to (or unassigned from) an agent. SkillAssigned { /// The agent whose skill set changed. diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 9d6e603..ff017a4 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -35,6 +35,7 @@ pub mod agent_tool_policy; pub mod background_task; pub mod conversation; pub mod conversation_log; +pub mod device; pub mod error; pub mod events; pub mod fileguard; @@ -140,6 +141,10 @@ pub use conversation_log::{ ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS, }; +pub use device::{ + AuthenticatedDevice, DeviceError, DeviceId, DeviceName, PairedDevice, SessionTokenHash, +}; + pub use fileguard::{ is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, OrchestratorDesignation, ReadLease, WriteLease, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 938b1de..2a20fa2 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -34,6 +34,7 @@ use crate::agent_tool_policy::AgentToolPolicy; use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; +use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice}; use crate::events::DomainEvent; use crate::ids::{ AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId, @@ -1688,6 +1689,45 @@ pub trait EmbedderPromptStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Persists paired devices and their session-token hashes. +#[async_trait] +pub trait DeviceSessionStore: Send + Sync { + /// Loads all paired devices. A missing store is represented as an empty list. + async fn load_devices(&self) -> Result, StoreError>; + + /// Replaces the persisted device list atomically enough for the filesystem + /// adapter's single-process use case. + async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError>; + + /// Removes one device by id. + async fn remove_device(&self, device_id: DeviceId) -> Result { + let mut devices = self.load_devices().await?; + let before = devices.len(); + devices.retain(|device| device.device_id != device_id); + if devices.len() != before { + self.save_devices(&devices).await?; + return Ok(true); + } + Ok(false) + } + + /// Authenticates a session token against persisted token hashes. + async fn authenticate_token( + &self, + token_bytes: &[u8], + ) -> Result, StoreError> { + for device in self.load_devices().await? { + if device.session_token_hash.verify_token_bytes(token_bytes) { + return Ok(Some(AuthenticatedDevice { + device_id: device.device_id, + token_hash: device.session_token_hash, + })); + } + } + Ok(None) + } +} + /// Reads/writes agent `.md` contexts and the project manifest, within a project. #[async_trait] pub trait AgentContextStore: Send + Sync { diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index f33a772..934386d 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -28,6 +28,7 @@ pub mod issues; pub mod mailbox; pub mod model_server; pub mod orchestrator; +pub mod pair_attempt_limiter; pub mod permission; pub mod process; pub mod pty; @@ -77,6 +78,7 @@ pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, REQUESTS_SUBDIR, }; +pub use pair_attempt_limiter::InMemoryPairAttemptLimiter; pub use permission::{ClaudePermissionProjector, CodexPermissionProjector}; pub use process::LocalProcessSpawner; pub use pty::PortablePtyAdapter; @@ -96,9 +98,9 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; pub use store::{ embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, - FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore, - FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, - FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, - StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, + FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, + FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, + OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/pair_attempt_limiter.rs b/crates/infrastructure/src/pair_attempt_limiter.rs new file mode 100644 index 0000000..ef04be1 --- /dev/null +++ b/crates/infrastructure/src/pair_attempt_limiter.rs @@ -0,0 +1,153 @@ +//! In-memory pairing-attempt limiter. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use application::{PairAttemptDecision, PairAttemptLimiter, RateLimitKey}; +use async_trait::async_trait; +use domain::ports::{Clock, StoreError}; + +const WINDOW_MS: i64 = 60_000; +const PER_ORIGIN_LIMIT: usize = 5; +const GLOBAL_LIMIT: usize = 30; + +/// Process-local limiter for failed pairing attempts. +pub struct InMemoryPairAttemptLimiter { + clock: Arc, + inner: Mutex, +} + +#[derive(Default)] +struct LimiterState { + by_origin: HashMap>, + global: Vec, +} + +impl InMemoryPairAttemptLimiter { + /// Creates a limiter using the supplied clock. + #[must_use] + pub fn new(clock: Arc) -> Self { + Self { + clock, + inner: Mutex::new(LimiterState::default()), + } + } +} + +#[async_trait] +impl PairAttemptLimiter for InMemoryPairAttemptLimiter { + async fn check(&self, key: RateLimitKey) -> Result { + let now = self.clock.now_millis().max(0); + let cutoff = now.saturating_sub(WINDOW_MS); + let mut inner = self.inner.lock().expect("pair limiter mutex poisoned"); + prune(&mut inner.global, cutoff); + let origin = origin_key(&key); + let origin_bucket = inner.by_origin.entry(origin).or_default(); + prune(origin_bucket, cutoff); + if origin_bucket.len() >= PER_ORIGIN_LIMIT || inner.global.len() >= GLOBAL_LIMIT { + return Ok(PairAttemptDecision::RateLimited); + } + Ok(PairAttemptDecision::Allowed) + } + + async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError> { + let now = self.clock.now_millis().max(0); + let cutoff = now.saturating_sub(WINDOW_MS); + let mut inner = self.inner.lock().expect("pair limiter mutex poisoned"); + prune(&mut inner.global, cutoff); + inner.global.push(now); + let origin = origin_key(&key); + let origin_bucket = inner.by_origin.entry(origin).or_default(); + prune(origin_bucket, cutoff); + origin_bucket.push(now); + Ok(()) + } +} + +fn origin_key(key: &RateLimitKey) -> String { + format!("{}:{}", key.route, key.origin) +} + +fn prune(bucket: &mut Vec, cutoff: i64) { + bucket.retain(|timestamp| *timestamp > cutoff); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicI64, Ordering}; + + struct FakeClock(AtomicI64); + + impl FakeClock { + fn new(now: i64) -> Self { + Self(AtomicI64::new(now)) + } + + fn set(&self, now: i64) { + self.0.store(now, Ordering::SeqCst); + } + } + + impl Clock for FakeClock { + fn now_millis(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } + } + + fn key(origin: &str) -> RateLimitKey { + RateLimitKey { + origin: origin.to_owned(), + route: "/api/pair".to_owned(), + } + } + + #[tokio::test] + async fn limits_five_failures_per_origin_per_minute_with_injected_clock() { + let clock = Arc::new(FakeClock::new(1_000)); + let limiter = InMemoryPairAttemptLimiter::new(Arc::clone(&clock) as Arc); + + for _ in 0..PER_ORIGIN_LIMIT { + assert_eq!( + limiter.check(key("1.2.3.4")).await.unwrap(), + PairAttemptDecision::Allowed + ); + limiter.record_failure(key("1.2.3.4")).await.unwrap(); + } + + assert_eq!( + limiter.check(key("1.2.3.4")).await.unwrap(), + PairAttemptDecision::RateLimited + ); + assert_eq!( + limiter.check(key("1.2.3.5")).await.unwrap(), + PairAttemptDecision::Allowed + ); + + clock.set(61_001); + assert_eq!( + limiter.check(key("1.2.3.4")).await.unwrap(), + PairAttemptDecision::Allowed + ); + } + + #[tokio::test] + async fn limits_thirty_failures_globally_per_minute() { + let clock = Arc::new(FakeClock::new(1_000)); + let limiter = InMemoryPairAttemptLimiter::new(clock as Arc); + + for n in 0..GLOBAL_LIMIT { + let key = key(&format!("10.0.0.{n}")); + assert_eq!( + limiter.check(key.clone()).await.unwrap(), + PairAttemptDecision::Allowed + ); + limiter.record_failure(key).await.unwrap(); + } + + assert_eq!( + limiter.check(key("10.0.0.99")).await.unwrap(), + PairAttemptDecision::RateLimited + ); + } +} diff --git a/crates/infrastructure/src/store/device_session.rs b/crates/infrastructure/src/store/device_session.rs new file mode 100644 index 0000000..cc9e1b3 --- /dev/null +++ b/crates/infrastructure/src/store/device_session.rs @@ -0,0 +1,176 @@ +//! Filesystem-backed persistent paired-device store. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::ports::{DeviceSessionStore, FileSystem, FsError, RemotePath, StoreError}; +use domain::PairedDevice; + +const DEVICES_DOC_VERSION: u8 = 1; +const SECURITY_DIR: &str = "security"; +const DEVICES_FILE: &str = "devices.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DevicesDoc { + version: u8, + devices: Vec, +} + +/// JSON-file implementation for `{app_data_dir}/security/devices.json`. +#[derive(Clone)] +pub struct FsDeviceSessionStore { + fs: Arc, + app_data_dir: String, +} + +impl FsDeviceSessionStore { + /// Builds the store from an injected filesystem port and app-data directory. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + fn security_dir(&self) -> RemotePath { + RemotePath::new(format!( + "{}/{}", + self.app_data_dir.trim_end_matches(['/', '\\']), + SECURITY_DIR + )) + } + + fn path(&self) -> RemotePath { + RemotePath::new(format!("{}/{}", self.security_dir().as_str(), DEVICES_FILE)) + } +} + +#[async_trait] +impl DeviceSessionStore for FsDeviceSessionStore { + async fn load_devices(&self) -> Result, StoreError> { + match self.fs.read(&self.path()).await { + Ok(bytes) => { + let doc: DevicesDoc = serde_json::from_slice(&bytes) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + if doc.version != DEVICES_DOC_VERSION { + return Err(StoreError::Serialization(format!( + "unsupported devices.json version {}", + doc.version + ))); + } + Ok(doc.devices) + } + Err(FsError::NotFound(_)) => Ok(Vec::new()), + Err(err) => Err(StoreError::Io(err.to_string())), + } + } + + async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> { + self.fs + .create_dir_all(&self.security_dir()) + .await + .map_err(|err| StoreError::Io(err.to_string()))?; + let doc = DevicesDoc { + version: DEVICES_DOC_VERSION, + devices: devices.to_vec(), + }; + let mut bytes = serde_json::to_vec_pretty(&doc) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&self.path(), &bytes) + .await + .map_err(|err| StoreError::Io(err.to_string())) + } +} + +#[cfg(test)] +mod tests { + use self::infrastructure_test_support::TempFs; + use super::*; + use domain::{DeviceId, DeviceName, SessionTokenHash}; + use uuid::Uuid; + + mod infrastructure_test_support { + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + use crate::LocalFileSystem; + use domain::ports::FileSystem; + + pub struct TempFs { + pub root: PathBuf, + pub fs: Arc, + } + + impl TempFs { + pub fn new() -> Self { + let root = std::env::temp_dir() + .join(format!("idea-devices-store-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + Self { + root, + fs: Arc::new(LocalFileSystem::new()), + } + } + + pub fn path(&self, rel: &str) -> PathBuf { + self.root.join(Path::new(rel)) + } + } + + impl Drop for TempFs { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + } + + fn device() -> PairedDevice { + PairedDevice { + device_id: DeviceId::from_uuid(Uuid::from_u128(1)), + name: DeviceName::new("Phone").unwrap(), + paired_at_ms: 1000, + last_seen_at_ms: 1000, + session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), + } + } + + #[tokio::test] + async fn missing_file_loads_empty_device_list() { + let temp = TempFs::new(); + let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy()); + + assert_eq!(store.load_devices().await.unwrap(), Vec::new()); + } + + #[tokio::test] + async fn devices_round_trip_in_v1_document() { + let temp = TempFs::new(); + let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy()); + + store.save_devices(&[device()]).await.unwrap(); + + let loaded = store.load_devices().await.unwrap(); + assert_eq!(loaded, vec![device()]); + let raw = std::fs::read_to_string(temp.path("security/devices.json")).unwrap(); + assert!(raw.contains("\"version\": 1")); + assert!(raw.contains("\"deviceId\"")); + assert!(raw.contains("\"sessionTokenHash\"")); + } + + #[tokio::test] + async fn corrupted_json_is_a_serialization_error() { + let temp = TempFs::new(); + std::fs::create_dir_all(temp.path("security")).unwrap(); + std::fs::write(temp.path("security/devices.json"), b"{not json").unwrap(); + let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy()); + + let err = store.load_devices().await.unwrap_err(); + + assert!(matches!(err, StoreError::Serialization(_))); + } +} diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 24ce547..032a11b 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -6,6 +6,7 @@ mod background_task; mod context; +mod device_session; mod embedder; mod live_state; mod memory; @@ -19,6 +20,7 @@ mod window_state; pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore}; pub use context::IdeaiContextStore; +pub use device_session::FsDeviceSessionStore; #[cfg(feature = "vector-onnx")] pub use embedder::OnnxEmbedder; #[cfg(feature = "vector-http")] diff --git a/crates/web-server/Cargo.toml b/crates/web-server/Cargo.toml index dc072d7..5b5c60c 100644 --- a/crates/web-server/Cargo.toml +++ b/crates/web-server/Cargo.toml @@ -25,8 +25,12 @@ uuid = { workspace = true } base64 = "0.22" bytes = "1.11" cookie = "0.18" +getrandom = { workspace = true } +hex = { workspace = true } http = "1.4" http-body-util = "0.1" +sha2 = { workspace = true } +subtle = { workspace = true } [features] vector-http = ["backend/vector-http"] diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 08ee401..77f8c14 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -3,7 +3,7 @@ //! The shared backend core stays unaware of HTTP, cookies, origins and //! WebSocket framing; this module owns the secure web driving adapter. -use std::collections::HashSet; +use std::collections::HashMap; use std::env; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; @@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use backend::stream::{OutputBridge, OutputSink, OutputSinkError}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; use bytes::Bytes; use cookie::{Cookie, SameSite}; use domain::events::DomainEvent; @@ -23,6 +25,8 @@ use http::{HeaderMap, Method, Response, StatusCode, Uri}; use http_body_util::{BodyExt, Full}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::{mpsc, oneshot}; @@ -30,11 +34,13 @@ use tokio::task::JoinHandle; use uuid::Uuid; use application::{ - CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, McpRuntime, OpenProjectInput, - ResizeTerminalInput, RotateConversationLogInput, WriteToTerminalInput, + AuthenticateSessionInput, CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, + ListDevicesInput, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, + RateLimitKey, RenameDeviceInput, ResizeTerminalInput, RevokeDeviceInput, + RotateConversationLogInput, TouchDeviceInput, WriteToTerminalInput, }; use domain::ports::PtyHandle; -use domain::{Project, SessionId}; +use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash}; use backend::dto::{ parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id, @@ -47,12 +53,17 @@ use backend::BackendCore; const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; const SESSION_COOKIE: &str = "idea_session"; +const SESSION_COOKIE_MAX_AGE_SECONDS: i64 = 34_560_000; +const PAIRING_CODE_TTL_SECONDS: i64 = 600; +const PAIRING_CODE_TTL_MS: i64 = PAIRING_CODE_TTL_SECONDS * 1000; +const PAIRING_CODE_HASH_CONTEXT: &[u8] = b"idea-pairing-code-v1\0"; const WS_PATH: &str = "/api/ws"; const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const WS_MAX_PAYLOAD: usize = 64 * 1024; const WS_OUTPUT_BUFFER: usize = 512; type ResponseBody = Full; +static NEXT_PAIRING_GENERATION_ID: AtomicU64 = AtomicU64::new(1); /// Runs the `idea --serve` subcommand from already-split CLI arguments. pub fn run_from_args(args: Vec) -> ExitCode { @@ -102,6 +113,8 @@ pub struct ServerConfig { pub app_data_dir: PathBuf, /// Built frontend assets root. pub web_root: PathBuf, + /// Generate and print one ephemeral pairing code after startup. + pub new_code: bool, } /// Authorized reverse proxy peer IP or CIDR range. @@ -180,6 +193,7 @@ impl ServerConfig { let mut trusted_proxies = Vec::new(); let mut app_data_dir = default_app_data_dir(); let mut web_root = None; + let mut new_code = false; let mut it = args.into_iter(); while let Some(arg) = it.next() { @@ -218,6 +232,7 @@ impl ServerConfig { .ok_or_else(|| "--web-root requires a path".to_owned())?; web_root = Some(PathBuf::from(value)); } + "--new-code" => new_code = true, "--help" | "-h" => return Err(Self::usage()), other => return Err(format!("unknown --serve argument: {other}")), } @@ -232,6 +247,7 @@ impl ServerConfig { trusted_proxies, app_data_dir, web_root, + new_code, }) } @@ -277,7 +293,7 @@ impl ServerConfig { /// Human-readable CLI usage. #[must_use] pub fn usage() -> String { - "usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy [--trusted-proxy IP_OR_CIDR]...]".to_owned() + "usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--new-code] [--allow-remote --public-origin https://host --trust-reverse-proxy [--trusted-proxy IP_OR_CIDR]...]".to_owned() } } @@ -293,7 +309,7 @@ pub enum EmbeddedServerState { /// Handle returned by [`run_embedded`] and [`run_embedded_with_core`]. pub struct EmbeddedServerHandle { url: String, - pairing_code: String, + state_ref: Arc, shutdown: Option>, task: JoinHandle>, state: EmbeddedServerState, @@ -307,10 +323,9 @@ impl EmbeddedServerHandle { &self.url } - /// Pairing code accepted by `POST /api/pair`. - #[must_use] - pub fn pairing_code(&self) -> &str { - &self.pairing_code + /// Generates a new ephemeral pairing code on the embedded server. + pub fn generate_pairing_code(&self) -> PairingCodeDto { + self.state_ref.generate_pairing_code() } /// Current lifecycle state. @@ -359,12 +374,11 @@ pub async fn run_embedded_with_core( .map_err(|err| format!("failed to read listener address: {err}"))?; let effective_config = config_with_effective_listen(config.clone(), local_addr); let state = Arc::new(ServerState::with_core(effective_config, core)); - let pairing_code = state.pairing_code().to_owned(); let (shutdown_tx, shutdown_rx) = oneshot::channel(); - let task = tokio::spawn(run_listener(listener, state, shutdown_rx)); + let task = tokio::spawn(run_listener(listener, Arc::clone(&state), shutdown_rx)); Ok(EmbeddedServerHandle { url: format!("http://{local_addr}"), - pairing_code, + state_ref: state, shutdown: Some(shutdown_tx), task, state: EmbeddedServerState::Running, @@ -418,12 +432,31 @@ fn validate_web_root(path: PathBuf, source: &str) -> Result { struct ServerState { config: ServerConfig, app: Arc, - pairing_code: String, - sessions: Mutex>, + pairing_code: Mutex>, ws_pty_bridge: Arc>, + active_connections: Arc, + _revocation_observer: Option>, security_logger: Arc, } +struct PairingCodeState { + code_hash: [u8; 32], + expires_at_ms: i64, + generation_id: u64, + used: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PairingCodeDto { + /// One-time pairing code, returned only at generation time. + pub code: String, + /// Expiration timestamp as epoch milliseconds. + pub expires_at_ms: u64, + /// Time-to-live in seconds. + pub ttl_seconds: i64, +} + impl ServerState { fn new(config: ServerConfig) -> Self { let core = Arc::new(BackendCore::build(config.app_data_dir.clone())); @@ -431,12 +464,18 @@ impl ServerState { } fn with_core(config: ServerConfig, core: Arc) -> Self { + let active_connections = Arc::new(ActiveConnectionRegistry::default()); + let revocation_observer = spawn_device_revocation_observer( + core.event_bus.raw_receiver(), + Arc::clone(&active_connections), + ); Self { app: core, config, - pairing_code: new_pairing_code(), - sessions: Mutex::new(HashSet::new()), + pairing_code: Mutex::new(None), ws_pty_bridge: Arc::new(OutputBridge::new()), + active_connections, + _revocation_observer: revocation_observer, security_logger: Arc::new(StderrSecurityLogger), } } @@ -452,40 +491,128 @@ impl ServerState { pairing_code: impl Into, security_logger: Arc, ) -> Self { - Self { - app: Arc::new(BackendCore::build(config.app_data_dir.clone())), + let core = Arc::new(BackendCore::build(config.app_data_dir.clone())); + let active_connections = Arc::new(ActiveConnectionRegistry::default()); + let revocation_observer = spawn_device_revocation_observer( + core.event_bus.raw_receiver(), + Arc::clone(&active_connections), + ); + let state = Self { + app: core, config, - pairing_code: pairing_code.into(), - sessions: Mutex::new(HashSet::new()), + pairing_code: Mutex::new(None), ws_pty_bridge: Arc::new(OutputBridge::new()), + active_connections, + _revocation_observer: revocation_observer, security_logger, + }; + state.set_pairing_code_for_test(pairing_code.into()); + state + } + + fn generate_pairing_code(&self) -> PairingCodeDto { + let code = new_pairing_code(); + let now = current_time_millis(); + let expires_at_ms = now.saturating_add(PAIRING_CODE_TTL_MS); + let generation_id = NEXT_PAIRING_GENERATION_ID.fetch_add(1, Ordering::Relaxed); + let mut guard = self + .pairing_code + .lock() + .expect("pairing code mutex poisoned"); + *guard = Some(PairingCodeState { + code_hash: pairing_code_hash(&code), + expires_at_ms, + generation_id, + used: false, + }); + PairingCodeDto { + code, + expires_at_ms: expires_at_ms.max(0) as u64, + ttl_seconds: PAIRING_CODE_TTL_SECONDS, } } - fn pairing_code(&self) -> &str { - &self.pairing_code + #[cfg(test)] + fn set_pairing_code_for_test(&self, code: String) { + let expires_at_ms = current_time_millis().saturating_add(PAIRING_CODE_TTL_MS); + let generation_id = NEXT_PAIRING_GENERATION_ID.fetch_add(1, Ordering::Relaxed); + let mut guard = self + .pairing_code + .lock() + .expect("pairing code mutex poisoned"); + *guard = Some(PairingCodeState { + code_hash: pairing_code_hash(&code), + expires_at_ms, + generation_id, + used: false, + }); } - fn create_session(&self) -> String { - let token = new_session_token(); - if let Ok(mut sessions) = self.sessions.lock() { - sessions.insert(token.clone()); + #[cfg(test)] + fn expire_pairing_code_for_test(&self) { + if let Some(code) = self + .pairing_code + .lock() + .expect("pairing code mutex poisoned") + .as_mut() + { + code.expires_at_ms = current_time_millis().saturating_sub(1); } - token } - fn has_session(&self, token: &str) -> bool { - self.sessions + fn consume_pairing_code(&self, code: &str) -> PairingCodeConsumeResult { + let now = current_time_millis(); + let candidate_hash = pairing_code_hash(code); + let mut guard = self + .pairing_code .lock() - .map(|sessions| sessions.contains(token)) - .unwrap_or(false) + .expect("pairing code mutex poisoned"); + let Some(current) = guard.as_mut() else { + return PairingCodeConsumeResult::InvalidOrExpired; + }; + if current.used || now > current.expires_at_ms { + return PairingCodeConsumeResult::InvalidOrExpired; + } + let matches: bool = current.code_hash.ct_eq(&candidate_hash).into(); + if !matches { + return PairingCodeConsumeResult::InvalidOrExpired; + } + let _generation_id = current.generation_id; + current.used = true; + PairingCodeConsumeResult::Valid } - fn revoke_session(&self, token: &str) -> bool { - self.sessions - .lock() - .map(|mut sessions| sessions.remove(token)) - .unwrap_or(false) + async fn authenticate_session(&self, token: &str) -> Option { + let token_bytes = decode_session_token(token).ok()?; + self + .app + .authenticate_session + .execute(AuthenticateSessionInput { token_bytes }) + .await + .unwrap_or_default() + } + + async fn touch_session(&self, device: &AuthenticatedDevice) { + let _ = self + .app + .touch_device + .execute(TouchDeviceInput { + device_id: device.device_id, + }) + .await; + } + + async fn revoke_session(&self, token: &str) -> bool { + let Some(device) = self.authenticate_session(token).await else { + return false; + }; + self.app + .revoke_device + .execute(RevokeDeviceInput { + device_id: device.device_id, + }) + .await + .is_ok() } fn log_security(&self, event: SecurityLogEvent) { @@ -493,6 +620,114 @@ impl ServerState { } } +fn spawn_device_revocation_observer( + mut rx: tokio::sync::broadcast::Receiver, + active_connections: Arc, +) -> Option> { + let Ok(handle) = tokio::runtime::Handle::try_current() else { + return None; + }; + Some(handle.spawn(async move { + loop { + match rx.recv().await { + Ok(DomainEvent::DeviceRevoked { device_id }) => { + active_connections.close_device(device_id); + } + Ok(DomainEvent::AllDevicesRevoked) => { + active_connections.close_all(); + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + })) +} + +#[derive(Default)] +struct ActiveConnectionRegistry { + next_id: AtomicU64, + connections: Mutex>>, +} + +struct ActiveConnectionHandle { + id: u64, + shutdown: oneshot::Sender<()>, +} + +struct ActiveConnectionRegistration { + device_id: DeviceId, + id: u64, + shutdown: oneshot::Receiver<()>, +} + +impl ActiveConnectionRegistry { + fn register(&self, device_id: DeviceId) -> ActiveConnectionRegistration { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (shutdown, rx) = oneshot::channel(); + self.connections + .lock() + .expect("active connection registry mutex poisoned") + .entry(device_id) + .or_default() + .push(ActiveConnectionHandle { id, shutdown }); + ActiveConnectionRegistration { + device_id, + id, + shutdown: rx, + } + } + + fn unregister(&self, device_id: DeviceId, id: u64) { + let mut connections = self + .connections + .lock() + .expect("active connection registry mutex poisoned"); + let Some(handles) = connections.get_mut(&device_id) else { + return; + }; + handles.retain(|handle| handle.id != id); + if handles.is_empty() { + connections.remove(&device_id); + } + } + + fn close_device(&self, device_id: DeviceId) { + let handles = self + .connections + .lock() + .expect("active connection registry mutex poisoned") + .remove(&device_id) + .unwrap_or_default(); + for handle in handles { + let _ = handle.shutdown.send(()); + } + } + + fn close_all(&self) { + let handles: Vec<_> = self + .connections + .lock() + .expect("active connection registry mutex poisoned") + .drain() + .flat_map(|(_, handles)| handles) + .collect(); + for handle in handles { + let _ = handle.shutdown.send(()); + } + } + + #[cfg(test)] + fn active_count(&self) -> usize { + self.connections + .lock() + .expect("active connection registry mutex poisoned") + .values() + .map(Vec::len) + .sum() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum SecurityLogEvent { PairingSucceeded { @@ -608,6 +843,7 @@ fn origin_label(origin: &Option) -> &str { } async fn run_server(config: ServerConfig) -> Result<(), String> { + let print_new_code = config.new_code; let listener = TcpListener::bind(config.listen) .await .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; @@ -616,7 +852,11 @@ async fn run_server(config: ServerConfig) -> Result<(), String> { .map_err(|err| format!("failed to read listener address: {err}"))?; let effective_config = config_with_effective_listen(config.clone(), local_addr); let state = Arc::new(ServerState::new(effective_config)); - eprintln!("IdeA pairing code: {}", state.pairing_code()); + if print_new_code { + let pairing = state.generate_pairing_code(); + eprintln!("IdeA pairing code: {}", pairing.code); + eprintln!("IdeA pairing code expires at: {}", pairing.expires_at_ms); + } eprintln!( "idea --serve: app data dir = {}", config.app_data_dir.display() @@ -827,8 +1067,8 @@ async fn handle_ws_upgrade( headers: HeaderMap, state: Arc, ) -> Result<(), String> { - let accept = match validate_ws_upgrade(&headers, Some(peer_ip), &state) { - Ok(accept) => accept, + let (accept, device) = match validate_ws_upgrade(&headers, Some(peer_ip), &state).await { + Ok(result) => result, Err(response) => return write_http_response(&mut stream, *response).await, }; @@ -842,17 +1082,15 @@ async fn handle_ws_upgrade( .write_all(response.as_bytes()) .await .map_err(|err| format!("failed to write websocket upgrade: {err}"))?; - run_ws_connection(stream, state).await + run_ws_connection(stream, state, device).await } -fn validate_ws_upgrade( +async fn validate_ws_upgrade( headers: &HeaderMap, peer_ip: Option, state: &ServerState, -) -> Result>> { - if let Err(response) = validate_reverse_proxy(headers, peer_ip, state, WS_PATH) { - return Err(response); - } +) -> Result<(String, AuthenticatedDevice), Box>> { + validate_reverse_proxy(headers, peer_ip, state, WS_PATH)?; let origin = match validate_request_origin(headers, &state.config) { Ok(origin) => origin, @@ -876,7 +1114,7 @@ fn validate_ws_upgrade( origin.as_deref(), ))); }; - if !state.has_session(&token) { + let Some(device) = state.authenticate_session(&token).await else { state.log_security(SecurityLogEvent::WsUpgradeRejected { origin: origin.clone(), reason: "invalid_session", @@ -887,7 +1125,8 @@ fn validate_ws_upgrade( "invalid session cookie", origin.as_deref(), ))); - } + }; + state.touch_session(&device).await; if !header_contains_token(headers, "upgrade", "websocket") || !header_contains_token(headers, "connection", "upgrade") { @@ -917,7 +1156,7 @@ fn validate_ws_upgrade( origin.as_deref(), ))); }; - Ok(websocket_accept(key)) + Ok((websocket_accept(key), device)) } fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool { @@ -934,10 +1173,13 @@ fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool async fn run_ws_connection( stream: tokio::net::TcpStream, state: Arc, + device: AuthenticatedDevice, ) -> Result<(), String> { let (mut reader, mut writer) = stream.into_split(); let (tx, mut rx) = mpsc::channel::(WS_OUTPUT_BUFFER); let owned = Arc::new(Mutex::new(Vec::<(SessionId, u64)>::new())); + let registration = state.active_connections.register(device.device_id); + let mut shutdown = registration.shutdown; let event_relay_task = spawn_ws_domain_event_relay(state.app.event_bus.raw_receiver(), tx.clone()); let writer_task = tokio::spawn(async move { @@ -954,17 +1196,22 @@ async fn run_ws_connection( }); loop { - let frame = match read_ws_frame(&mut reader).await { - Ok(frame) => frame, - Err(err) => { - let _ = tx - .send(ServerFrame::error( - None, - None, - "WS_PROTOCOL", - err.to_string(), - )) - .await; + let frame = tokio::select! { + frame = read_ws_frame(&mut reader) => match frame { + Ok(frame) => frame, + Err(err) => { + let _ = tx + .send(ServerFrame::error( + None, + None, + "WS_PROTOCOL", + err.to_string(), + )) + .await; + break; + } + }, + _ = &mut shutdown => { break; } }; @@ -1006,6 +1253,9 @@ async fn run_ws_connection( state.ws_pty_bridge.unregister_if(session, *gen); } } + state + .active_connections + .unregister(registration.device_id, registration.id); event_relay_task.abort(); drop(tx); writer_task @@ -1413,6 +1663,95 @@ async fn dispatch_http( match (method, uri.path()) { (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, + (Method::POST, "/api/pairing-code") => { + let Some(token) = session_cookie(&headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ); + }; + let Some(device) = state.authenticate_session(&token).await else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ); + }; + state.touch_session(&device).await; + let dto = state.generate_pairing_code(); + let body = serde_json::to_value(dto).expect("pairing code dto serializes"); + let mut response = json_response(StatusCode::OK, &body, origin.as_deref()); + refresh_session_cookie(&mut response, &state, &token); + response + } + (Method::GET, "/api/devices") => { + let (token, device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + let mut response = + list_devices_response(Arc::clone(&state), &device, origin.as_deref()).await; + refresh_session_cookie(&mut response, &state, &token); + response + } + (Method::POST, "/api/devices/revoke-all") => { + let (_token, _device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + revoke_all_devices_response(Arc::clone(&state), origin.as_deref()).await + } + (Method::POST, path) if path.starts_with("/api/devices/") && path.ends_with("/rename") => { + let (token, _device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + let raw_id = path + .trim_start_matches("/api/devices/") + .trim_end_matches("/rename") + .trim_end_matches('/'); + let device_id = match parse_device_id(raw_id) { + Ok(device_id) => device_id, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + err, + origin.as_deref(), + ); + } + }; + let mut response = + rename_device_response(body, Arc::clone(&state), device_id, origin.as_deref()) + .await; + refresh_session_cookie(&mut response, &state, &token); + response + } + (Method::POST, path) if path.starts_with("/api/devices/") && path.ends_with("/revoke") => { + let (_token, _device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + let raw_id = path + .trim_start_matches("/api/devices/") + .trim_end_matches("/revoke") + .trim_end_matches('/'); + let device_id = match parse_device_id(raw_id) { + Ok(device_id) => device_id, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + err, + origin.as_deref(), + ); + } + }; + revoke_device_response(Arc::clone(&state), device_id, origin.as_deref()).await + } (Method::POST, "/api/logout") => { let Some(token) = session_cookie(&headers) else { return error_response( @@ -1422,7 +1761,7 @@ async fn dispatch_http( origin.as_deref(), ); }; - if !state.revoke_session(&token) { + if !state.revoke_session(&token).await { return error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", @@ -1444,17 +1783,34 @@ async fn dispatch_http( origin.as_deref(), ); }; - if !state.has_session(&token) { + let Some(device) = state.authenticate_session(&token).await else { return error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "invalid session cookie", origin.as_deref(), ); - } - invoke(body, state, origin.as_deref()).await + }; + state.touch_session(&device).await; + let mut response = invoke(body, Arc::clone(&state), origin.as_deref()).await; + refresh_session_cookie(&mut response, &state, &token); + response } - (_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response( + ( + _, + "/api/pair" + | "/api/pairing-code" + | "/api/invoke" + | "/api/logout" + | "/api/devices" + | "/api/devices/revoke-all", + ) => error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + origin.as_deref(), + ), + (_, path) if path.starts_with("/api/devices/") => error_response( StatusCode::METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "method not allowed", @@ -1590,6 +1946,170 @@ fn content_type(path: &Path) -> &'static str { #[derive(Deserialize)] struct PairRequest { code: String, + name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DeviceDto { + device_id: String, + name: String, + paired_at_ms: u64, + last_seen_at_ms: u64, + is_current_device: bool, +} + +#[derive(Deserialize)] +struct RenameDeviceRequest { + name: String, +} + +fn parse_device_id(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(DeviceId::from_uuid) + .map_err(|_| "invalid device id".to_owned()) +} + +async fn authenticated_device( + headers: &HeaderMap, + state: &ServerState, +) -> Result<(String, AuthenticatedDevice), Response> { + let origin = request_origin(headers); + let Some(token) = session_cookie(headers) else { + return Err(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + )); + }; + let Some(device) = state.authenticate_session(&token).await else { + return Err(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + )); + }; + state.touch_session(&device).await; + Ok((token, device)) +} + +async fn list_devices_response( + state: Arc, + device: &AuthenticatedDevice, + origin: Option<&str>, +) -> Response { + match state + .app + .list_devices + .execute(ListDevicesInput { + current_device_id: Some(device.device_id), + }) + .await + { + Ok(output) => { + let devices: Vec<_> = output + .devices + .into_iter() + .map(|device| DeviceDto { + device_id: device.device_id.to_string(), + name: device.name, + paired_at_ms: device.paired_at_ms, + last_seen_at_ms: device.last_seen_at_ms, + is_current_device: device.is_current_device, + }) + .collect(); + json_response(StatusCode::OK, &json!({ "devices": devices }), origin) + } + Err(err) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.code(), + err.to_string(), + origin, + ), + } +} + +async fn rename_device_response( + body: Bytes, + state: Arc, + device_id: DeviceId, + origin: Option<&str>, +) -> Response { + let request = match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid rename request: {err}"), + origin, + ); + } + }; + match state + .app + .rename_device + .execute(RenameDeviceInput { + device_id, + name: request.name, + }) + .await + { + Ok(()) => json_response(StatusCode::OK, &json!({ "renamed": true }), origin), + Err(err @ application::AppError::Invalid(_)) => { + error_response(StatusCode::BAD_REQUEST, err.code(), err.to_string(), origin) + } + Err(err @ application::AppError::NotFound(_)) => { + error_response(StatusCode::NOT_FOUND, err.code(), err.to_string(), origin) + } + Err(err) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.code(), + err.to_string(), + origin, + ), + } +} + +async fn revoke_device_response( + state: Arc, + device_id: DeviceId, + origin: Option<&str>, +) -> Response { + match state + .app + .revoke_device + .execute(RevokeDeviceInput { device_id }) + .await + { + Ok(()) => json_response(StatusCode::OK, &json!({ "revoked": true }), origin), + Err(err @ application::AppError::NotFound(_)) => { + error_response(StatusCode::NOT_FOUND, err.code(), err.to_string(), origin) + } + Err(err) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.code(), + err.to_string(), + origin, + ), + } +} + +async fn revoke_all_devices_response( + state: Arc, + origin: Option<&str>, +) -> Response { + match state.app.revoke_all_devices.execute().await { + Ok(()) => json_response(StatusCode::OK, &json!({ "revoked": true }), origin), + Err(err) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.code(), + err.to_string(), + origin, + ), + } } async fn pair( @@ -1609,34 +2129,102 @@ async fn pair( } }; - if request.code != state.pairing_code() { + let limit_key = RateLimitKey { + origin: origin.unwrap_or("").to_owned(), + route: "/api/pair".to_owned(), + }; + match state + .app + .pair_attempt_limiter + .check(limit_key.clone()) + .await + { + Ok(PairAttemptDecision::Allowed) => {} + Ok(PairAttemptDecision::RateLimited) => { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "rate_limited", + }); + return error_response( + StatusCode::TOO_MANY_REQUESTS, + "rate_limited", + "too many pairing attempts", + origin, + ); + } + Err(_) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL", + "pairing limiter failed", + origin, + ); + } + } + + let device_name = match DeviceName::new(request.name) { + Ok(name) => name, + Err(err) => { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "invalid_name", + }); + return error_response( + StatusCode::BAD_REQUEST, + "invalid_name", + err.to_string(), + origin, + ); + } + }; + + if state.consume_pairing_code(&request.code) != PairingCodeConsumeResult::Valid { + let _ = state + .app + .pair_attempt_limiter + .record_failure(limit_key) + .await; state.log_security(SecurityLogEvent::PairingFailed { origin: origin.map(str::to_owned), - reason: "wrong_code", + reason: "invalid_or_expired", }); return error_response( StatusCode::FORBIDDEN, - "FORBIDDEN", + "invalid_or_expired", "invalid pairing code", origin, ); } - let token = state.create_session(); + let token = new_session_token(); + let token_hash = SessionTokenHash::from_token_bytes(&token.bytes); + let pair_result = state + .app + .pair_device + .execute(PairDeviceInput { + name: device_name.as_str().to_owned(), + session_token_hash: token_hash, + }) + .await; + + if pair_result.is_err() { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "pair_device_failed", + }); + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL", + "failed to pair device", + origin, + ); + } + state.log_security(SecurityLogEvent::PairingSucceeded { origin: origin.map(str::to_owned), }); - let cookie = Cookie::build((SESSION_COOKIE, token)) - .path("/") - .http_only(true) - .secure(state.config.secure_cookie()) - .same_site(SameSite::Strict) - .build(); let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), - ); + refresh_session_cookie(&mut response, &state, &token.value); response } @@ -1655,6 +2243,28 @@ fn logout_response(state: &ServerState, origin: Option<&str>) -> Response, state: &ServerState, token: &str) { + let cookie = Cookie::build((SESSION_COOKIE, token.to_owned())) + .path("/") + .http_only(true) + .secure(state.config.secure_cookie()) + .same_site(SameSite::Strict) + .max_age(cookie::time::Duration::seconds( + SESSION_COOKIE_MAX_AGE_SECONDS, + )) + .build(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), + ); +} + +#[derive(Debug, Clone)] +struct SessionToken { + value: String, + bytes: [u8; 32], +} + #[derive(Deserialize)] struct InvokeRequest { command: String, @@ -2218,17 +2828,43 @@ fn default_app_data_dir() -> PathBuf { } fn new_pairing_code() -> String { - Uuid::new_v4() - .simple() - .to_string() - .chars() - .take(8) - .collect::() - .to_ascii_uppercase() + let mut bytes = [0_u8; 4]; + getrandom::fill(&mut bytes).expect("OS CSPRNG must be available for pairing codes"); + hex::encode_upper(bytes) } -fn new_session_token() -> String { - format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PairingCodeConsumeResult { + Valid, + InvalidOrExpired, +} + +fn pairing_code_hash(code: &str) -> [u8; 32] { + let normalized = application::normalize_pairing_code(code); + let mut hasher = Sha256::new(); + hasher.update(PAIRING_CODE_HASH_CONTEXT); + hasher.update(normalized.as_bytes()); + hasher.finalize().into() +} + +fn current_time_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +fn new_session_token() -> SessionToken { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).expect("OS CSPRNG must be available for session tokens"); + SessionToken { + value: URL_SAFE_NO_PAD.encode(bytes), + bytes, + } +} + +fn decode_session_token(token: &str) -> Result, base64::DecodeError> { + URL_SAFE_NO_PAD.decode(token.as_bytes()) } fn invalid_args_error(err: serde_json::Error) -> ErrorDto { @@ -2773,6 +3409,7 @@ mod tests { trusted_proxies: Vec::new(), app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), web_root: create_web_root(), + new_code: false, } } @@ -2800,6 +3437,19 @@ mod tests { assert_eq!(default_app_data_dir(), override_dir); } + #[test] + fn serve_args_parse_new_code_without_persisting_config_side_effects() { + let web_root = create_web_root(); + let config = ServerConfig::from_args(vec![ + "--web-root".to_owned(), + web_root.to_string_lossy().into_owned(), + "--new-code".to_owned(), + ]) + .unwrap(); + + assert!(config.new_code); + } + fn state() -> Arc { Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) } @@ -2841,7 +3491,9 @@ mod tests { } handle_request_from_peer( builder - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .body(Full::new(Bytes::from_static( + br#"{"code":"PAIR1234","name":"Test device"}"#, + ))) .unwrap(), state, Some(peer.parse().unwrap()), @@ -2879,10 +3531,11 @@ mod tests { let handle = run_embedded_with_core(config, Arc::clone(&core)) .await .unwrap(); + let pairing = handle.generate_pairing_code(); let (pair_status, _, pair_headers) = embedded_json_request( handle.url(), "/api/pair", - json!({ "code": handle.pairing_code() }), + json!({ "code": pairing.code, "name": "Test device" }), &[], ) .await; @@ -3026,7 +3679,7 @@ mod tests { state, Method::POST, "/api/pair", - json!({ "code": "PAIR1234" }), + json!({ "code": "PAIR1234", "name": "Test device" }), &[], ) .await; @@ -3042,6 +3695,38 @@ mod tests { .to_owned() } + async fn pair_and_cookie_with(state: Arc, code: &str, name: &str) -> String { + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": code, "name": name }), + &[], + ) + .await; + response + .headers() + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() + } + + async fn authenticated_device_id(state: &ServerState, cookie: &str) -> DeviceId { + let token = cookie + .strip_prefix("idea_session=") + .expect("test cookie contains session token"); + state + .authenticate_session(token) + .await + .expect("test cookie authenticates") + .device_id + } + async fn create_project_for_test(state: &Arc, name: &str) -> String { let root = std::env::temp_dir() .join(format!("idea-server-project-{}", Uuid::new_v4())) @@ -3303,59 +3988,62 @@ mod tests { ); } - #[test] - fn websocket_upgrade_requires_valid_cookie() { + #[tokio::test] + async fn websocket_upgrade_requires_valid_cookie() { let state = state(); let headers = ws_headers("http://127.0.0.1:17373", None); - let response = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err(); + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } - #[test] - fn websocket_upgrade_rejects_invalid_cookie() { + #[tokio::test] + async fn websocket_upgrade_rejects_invalid_cookie() { let state = state(); let headers = ws_headers( "http://127.0.0.1:17373", Some("idea_session=not-a-valid-session"), ); - let response = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err(); + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } - #[test] - fn websocket_upgrade_requires_allowed_origin() { + #[tokio::test] + async fn websocket_upgrade_requires_allowed_origin() { let state = state(); - let token = state.create_session(); - let headers = ws_headers( - "https://evil.example", - Some(&format!("idea_session={token}")), - ); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let headers = ws_headers("https://evil.example", Some(&cookie)); - let response = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err(); + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } - #[test] - fn websocket_upgrade_accepts_valid_cookie_and_origin() { + #[tokio::test] + async fn websocket_upgrade_accepts_valid_cookie_and_origin() { let state = state(); - let token = state.create_session(); - let headers = ws_headers( - "http://127.0.0.1:17373", - Some(&format!("idea_session={token}")), - ); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); - let accept = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap(); + let (accept, device) = + validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap(); assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + assert_eq!( + device.device_id, + authenticated_device_id(&state, &cookie).await + ); } #[test] @@ -4304,6 +4992,8 @@ mod tests { async fn logout_revokes_session_for_http_and_websocket() { let state = state(); let cookie = pair_and_cookie(Arc::clone(&state)).await; + let device_id = authenticated_device_id(&state, &cookie).await; + let registration = state.active_connections.register(device_id); let response = request( Arc::clone(&state), @@ -4337,9 +5027,158 @@ mod tests { assert_eq!(body["code"], "UNAUTHORIZED"); let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); - let response = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err(); + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + tokio::time::timeout(Duration::from_secs(2), registration.shutdown) + .await + .expect("logout revocation closes active connection") + .expect("shutdown sender is delivered"); + } + + #[tokio::test] + async fn devices_list_rename_and_revoke_expose_safe_contract() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + Arc::clone(&state), + Method::GET, + "/api/devices", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::OK); + let devices = body["devices"].as_array().unwrap(); + assert_eq!(devices.len(), 1); + let row = devices[0].as_object().unwrap(); + assert_eq!(row["name"], "Test device"); + assert_eq!(row["isCurrentDevice"], true); + assert!(row.contains_key("deviceId")); + assert!(row["pairedAtMs"].is_number()); + assert!(row["lastSeenAtMs"].is_number()); + assert!(!row.contains_key("sessionTokenHash")); + assert!(!row.contains_key("ip")); + assert!(!row.contains_key("userAgent")); + let device_id = row["deviceId"].as_str().unwrap().to_owned(); + + let response = request( + Arc::clone(&state), + Method::POST, + &format!("/api/devices/{device_id}/rename"), + json!({ "name": "Renamed phone" }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "renamed": true })); + + let response = request( + Arc::clone(&state), + Method::GET, + "/api/devices", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (_, body, _) = response_json(response).await; + assert_eq!(body["devices"][0]["name"], "Renamed phone"); + + let response = request( + Arc::clone(&state), + Method::POST, + &format!("/api/devices/{device_id}/revoke"), + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "revoked": true })); + + let response = request( + Arc::clone(&state), + Method::GET, + "/api/devices", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + } + + #[tokio::test] + async fn device_revocation_closes_only_matching_active_connections() { + let state = state(); + let cookie_a = pair_and_cookie_with(Arc::clone(&state), "PAIR1234", "Phone A").await; + state.set_pairing_code_for_test("PAIR5678".to_owned()); + let cookie_b = pair_and_cookie_with(Arc::clone(&state), "PAIR5678", "Phone B").await; + let device_a = authenticated_device_id(&state, &cookie_a).await; + let device_b = authenticated_device_id(&state, &cookie_b).await; + let registration_a = state.active_connections.register(device_a); + let mut registration_b = state.active_connections.register(device_b); + + let response = request( + Arc::clone(&state), + Method::POST, + &format!("/api/devices/{device_a}/revoke"), + json!({}), + &[("cookie", &cookie_b)], + ) + .await; + let (status, _, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + tokio::time::timeout(Duration::from_secs(2), registration_a.shutdown) + .await + .expect("revoked device connection closes") + .expect("shutdown sender is delivered"); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut registration_b.shutdown) + .await + .is_err() + ); + assert_eq!(state.active_connections.active_count(), 1); + } + + #[tokio::test] + async fn revoke_all_closes_all_active_connections() { + let state = state(); + let cookie_a = pair_and_cookie_with(Arc::clone(&state), "PAIR1234", "Phone A").await; + state.set_pairing_code_for_test("PAIR5678".to_owned()); + let cookie_b = pair_and_cookie_with(Arc::clone(&state), "PAIR5678", "Phone B").await; + let device_a = authenticated_device_id(&state, &cookie_a).await; + let device_b = authenticated_device_id(&state, &cookie_b).await; + let registration_a = state.active_connections.register(device_a); + let registration_b = state.active_connections.register(device_b); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/devices/revoke-all", + json!({}), + &[("cookie", &cookie_a)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "revoked": true })); + tokio::time::timeout(Duration::from_secs(2), registration_a.shutdown) + .await + .expect("first connection closes") + .expect("shutdown sender is delivered"); + tokio::time::timeout(Duration::from_secs(2), registration_b.shutdown) + .await + .expect("second connection closes") + .expect("shutdown sender is delivered"); + assert_eq!(state.active_connections.active_count(), 0); } #[tokio::test] @@ -4357,7 +5196,9 @@ mod tests { .uri("/api/pair") .header(ORIGIN, "https://evil.example") .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .body(Full::new(Bytes::from_static( + br#"{"code":"PAIR1234","name":"Test device"}"#, + ))) .unwrap(), Arc::clone(&state), ) @@ -4368,7 +5209,7 @@ mod tests { Arc::clone(&state), Method::POST, "/api/pair", - json!({ "code": "WRONG999" }), + json!({ "code": "WRONG999", "name": "Test device" }), &[], ) .await; @@ -4388,7 +5229,7 @@ mod tests { "http://127.0.0.1:17373", Some("idea_session=invalid-token-value"), ); - let _ = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state); + let _ = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).await; let events = logger.events(); assert!(events.iter().any(|event| matches!( @@ -4398,7 +5239,7 @@ mod tests { assert!(events.iter().any(|event| matches!( event, SecurityLogEvent::PairingFailed { - reason: "wrong_code", + reason: "invalid_or_expired", .. } ))); @@ -4433,20 +5274,213 @@ mod tests { state, Method::POST, "/api/pair", - json!({ "code": "WRONG999" }), + json!({ "code": "WRONG999", "name": "Test device" }), &[], ) .await; let (status, body, headers) = response_json(response).await; assert_eq!(status, StatusCode::FORBIDDEN); - assert_eq!(body["code"], "FORBIDDEN"); + assert_eq!(body["code"], "invalid_or_expired"); assert!( !headers.contains_key(SET_COOKIE), "wrong pairing code must not issue a session cookie" ); } + #[tokio::test] + async fn pairing_rejects_when_no_code_exists_at_boot() { + let state = Arc::new(ServerState::new(test_config())); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Test device" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invalid_or_expired"); + assert!(!headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn pairing_code_expires_and_returns_public_invalid_or_expired() { + let state = state(); + state.expire_pairing_code_for_test(); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Test device" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invalid_or_expired"); + assert!(!headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn pairing_code_is_single_use() { + let state = state(); + let first = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "First device" }), + &[], + ) + .await; + let (first_status, _, first_headers) = response_json(first).await; + + let second = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Second device" }), + &[], + ) + .await; + let (second_status, second_body, second_headers) = response_json(second).await; + + assert_eq!(first_status, StatusCode::OK); + assert!(first_headers.contains_key(SET_COOKIE)); + assert_eq!(second_status, StatusCode::FORBIDDEN); + assert_eq!(second_body["code"], "invalid_or_expired"); + assert!(!second_headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn generating_new_pairing_code_invalidates_previous_code() { + let state = Arc::new(ServerState::new(test_config())); + let old = state.generate_pairing_code(); + let new = state.generate_pairing_code(); + + let old_response = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": old.code, "name": "Old device" }), + &[], + ) + .await; + let (old_status, old_body, _) = response_json(old_response).await; + + let new_response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": new.code, "name": "New device" }), + &[], + ) + .await; + let (new_status, _, new_headers) = response_json(new_response).await; + + assert_eq!(old_status, StatusCode::FORBIDDEN); + assert_eq!(old_body["code"], "invalid_or_expired"); + assert_eq!(new_status, StatusCode::OK); + assert!(new_headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn pairing_code_endpoint_requires_auth_and_returns_ttl() { + let state = state(); + let unauthenticated = request( + Arc::clone(&state), + Method::POST, + "/api/pairing-code", + json!({}), + &[], + ) + .await; + let (unauth_status, _, _) = response_json(unauthenticated).await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/pairing-code", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(unauth_status, StatusCode::UNAUTHORIZED); + assert_eq!(status, StatusCode::OK); + assert_eq!(body["ttlSeconds"], 600); + assert_eq!(body["code"].as_str().unwrap().len(), 8); + assert!(headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn invalid_or_expired_response_does_not_leak_cause() { + let absent = Arc::new(ServerState::new(test_config())); + let expired = state(); + expired.expire_pairing_code_for_test(); + let used = state(); + let _ = pair_and_cookie(Arc::clone(&used)).await; + + let cases = [ + (absent, json!({ "code": "PAIR1234", "name": "Absent" })), + (expired, json!({ "code": "PAIR1234", "name": "Expired" })), + (used, json!({ "code": "PAIR1234", "name": "Used" })), + (state(), json!({ "code": "WRONG999", "name": "Wrong" })), + ]; + + let mut bodies = Vec::new(); + for (state, body) in cases { + let response = request(state, Method::POST, "/api/pair", body, &[]).await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::FORBIDDEN); + bodies.push(body); + } + + assert!(bodies + .iter() + .all(|body| body["code"] == "invalid_or_expired")); + assert!(bodies.windows(2).all(|pair| pair[0] == pair[1])); + } + + #[tokio::test] + async fn pairing_rate_limits_failures_by_origin() { + let state = state(); + + for _ in 0..5 { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "WRONG999", "name": "Test device" }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invalid_or_expired"); + } + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "WRONG999", "name": "Test device" }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(body["code"], "rate_limited"); + } + #[tokio::test] async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() { let state = state(); @@ -4455,7 +5489,7 @@ mod tests { state, Method::POST, "/api/pair", - json!({ "code": "PAIR1234" }), + json!({ "code": "PAIR1234", "name": "Test device" }), &[], ) .await; @@ -4467,12 +5501,69 @@ mod tests { assert!(cookie.contains("idea_session=")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Strict")); + assert!(cookie.contains("Max-Age=34560000")); assert!( !cookie.contains("Secure"), "loopback dev over HTTP deliberately avoids Secure so browsers store it" ); } + #[tokio::test] + async fn authenticated_invoke_renews_session_cookie_max_age() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, _, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let renewed = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); + assert!(renewed.contains(&cookie)); + assert!(renewed.contains("Max-Age=34560000")); + assert!(renewed.contains("HttpOnly")); + assert!(renewed.contains("SameSite=Strict")); + } + + #[tokio::test] + async fn pairing_rejects_missing_device_name() { + let state = state(); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_name"); + assert!(!headers.contains_key(SET_COOKIE)); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Recovered device" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "paired": true })); + assert!(headers.contains_key(SET_COOKIE)); + } + #[tokio::test] async fn pairing_sets_secure_cookie_for_remote_https_origin() { let config = ServerConfig { @@ -4492,7 +5583,9 @@ mod tests { .header("x-forwarded-proto", "https") .header("x-forwarded-host", "idea.example.com") .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .body(Full::new(Bytes::from_static( + br#"{"code":"PAIR1234","name":"Test device"}"#, + ))) .unwrap(); *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = HeaderValue::from_static("application/json"); @@ -4506,6 +5599,7 @@ mod tests { assert!(cookie.contains("Secure")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Strict")); + assert!(cookie.contains("Max-Age=34560000")); } #[tokio::test] @@ -4640,7 +5734,9 @@ mod tests { .uri("/api/pair") .header(ORIGIN, "https://evil.example") .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .body(Full::new(Bytes::from_static( + br#"{"code":"PAIR1234","name":"Test device"}"#, + ))) .unwrap(); *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = HeaderValue::from_static("application/json");