feat(backend): appareils appairés persistants, révocables et code éphémère (#77 B1-B4)

L'appairage ne survivait pas au redémarrage et son code, permanent, était
imprimé sur la sortie standard. Un appareil appairé devient une entité
persistante, nommée et révocable, derrière un code désormais éphémère.

- B1 : port DeviceSessionStore et adapter FsDeviceSessionStore, entités de
  domaine (PairedDevice, DeviceId, SessionTokenHash, DeviceName). Les tokens
  sont hachés en SHA-256 et comparés en temps constant (subtle) : le store
  ne peut pas rejouer une session qu'il a servie. Cookie Max-Age 400 j à
  renouvellement glissant, lastSeenAtMs throttlé.
- B2 : code éphémère en mémoire, TTL 10 min et usage unique, toute
  génération invalidant la précédente. POST /api/pairing-code authentifiée,
  flag --new-code. Le code est retiré du boot et l'eprintln! qui l'imprimait
  est supprimé.
- B3 : endpoints devices (list/rename/revoke/revoke-all/logout), event
  DeviceRevoked et ActiveConnectionRegistry par device_id, fermant sans
  délai les WebSockets d'un appareil révoqué.
- B4 : port PairAttemptLimiter et adapter mémoire, rate-limit par origine et
  global sur horloge injectée, donc testable sans attente réelle.

La normalisation du code passe côté serveur : elle absorbe la dette #76, que
la seule normalisation frontend de #75 ne faisait que masquer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:26:56 +02:00
parent 9463b7e6bf
commit 8fe93d1652
21 changed files with 2603 additions and 185 deletions

View File

@ -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,
};

View File

@ -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<dyn Clock>,
inner: Mutex<LimiterState>,
}
#[derive(Default)]
struct LimiterState {
by_origin: HashMap<String, Vec<i64>>,
global: Vec<i64>,
}
impl InMemoryPairAttemptLimiter {
/// Creates a limiter using the supplied clock.
#[must_use]
pub fn new(clock: Arc<dyn Clock>) -> Self {
Self {
clock,
inner: Mutex::new(LimiterState::default()),
}
}
}
#[async_trait]
impl PairAttemptLimiter for InMemoryPairAttemptLimiter {
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, 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);
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<i64>, 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<dyn Clock>);
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<dyn Clock>);
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
);
}
}

View File

@ -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<PairedDevice>,
}
/// JSON-file implementation for `{app_data_dir}/security/devices.json`.
#[derive(Clone)]
pub struct FsDeviceSessionStore {
fs: Arc<dyn FileSystem>,
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<dyn FileSystem>, app_data_dir: impl Into<String>) -> 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<Vec<PairedDevice>, 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<dyn FileSystem>,
}
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(_)));
}
}

View File

@ -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")]