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

@ -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<dyn DeviceSessionStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
}
impl PairDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(
store: Arc<dyn DeviceSessionStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
) -> 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<PairDeviceOutput, AppError> {
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<PairAttemptDecision, StoreError>;
/// 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<u8>,
}
/// Authenticates a persisted device session.
pub struct AuthenticateSession {
store: Arc<dyn DeviceSessionStore>,
}
impl AuthenticateSession {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> 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<Option<AuthenticatedDevice>, 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<DeviceId>,
}
/// Output for [`ListDevices`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListDevicesOutput {
/// Paired devices.
pub devices: Vec<DeviceView>,
}
/// Lists paired devices without exposing transport-sensitive fields.
pub struct ListDevices {
store: Arc<dyn DeviceSessionStore>,
}
impl ListDevices {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
Self { store }
}
/// Executes the listing.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: ListDevicesInput) -> Result<ListDevicesOutput, AppError> {
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<dyn DeviceSessionStore>,
}
impl RenameDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> 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<dyn DeviceSessionStore>,
events: Arc<dyn EventBus>,
}
impl RevokeDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> 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<dyn DeviceSessionStore>,
events: Arc<dyn EventBus>,
}
impl RevokeAllDevices {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> 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<dyn DeviceSessionStore>,
clock: Arc<dyn Clock>,
}
impl TouchDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, clock: Arc<dyn Clock>) -> Self {
Self { store, clock }
}
/// Executes the touch.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: TouchDeviceInput) -> Result<TouchDeviceOutput, AppError> {
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<Vec<PairedDevice>>,
saves: Mutex<usize>,
}
#[async_trait]
impl DeviceSessionStore for FakeStore {
async fn load_devices(&self) -> Result<Vec<PairedDevice>, 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<dyn DeviceSessionStore>,
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<dyn DeviceSessionStore>,
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);
}
}

View File

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