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");