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:
184
crates/domain/src/device.rs
Normal file
184
crates/domain/src/device.rs
Normal file
@ -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<String>) -> Result<Self, DeviceError> {
|
||||
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<String>) -> Result<Self, DeviceError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Vec<PairedDevice>, 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<bool, StoreError> {
|
||||
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<Option<AuthenticatedDevice>, 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 {
|
||||
|
||||
Reference in New Issue
Block a user