Skip to main content

clasp_registry/
validator.rs

1//! Entity token validator implementing the clasp-core TokenValidator trait
2
3use std::sync::Arc;
4
5use clasp_core::security::{Scope, TokenInfo, TokenValidator, ValidationResult};
6
7use crate::entity::Entity;
8use crate::store::EntityStore;
9use crate::token::{parse_token, verify_token_signature, ENTITY_TOKEN_PREFIX};
10
11/// Token validator for entity-signed tokens.
12///
13/// Plugs into the existing `ValidatorChain` alongside `CpskValidator`.
14/// Token format: "ent_<base64url(msgpack(entity_id + timestamp + signature))>"
15pub struct EntityValidator {
16    store: Arc<dyn EntityStore>,
17    /// Maximum token age in seconds (0 = no limit)
18    max_token_age: u64,
19}
20
21impl EntityValidator {
22    /// Create a new entity validator
23    pub fn new(store: Arc<dyn EntityStore>) -> Self {
24        Self {
25            store,
26            max_token_age: 0,
27        }
28    }
29
30    /// Set maximum token age (tokens older than this are rejected)
31    pub fn with_max_token_age(mut self, seconds: u64) -> Self {
32        self.max_token_age = seconds;
33        self
34    }
35}
36
37impl TokenValidator for EntityValidator {
38    fn validate(&self, token: &str) -> ValidationResult {
39        // Check prefix -- if not ours, pass to next validator
40        if !token.starts_with(ENTITY_TOKEN_PREFIX) {
41            return ValidationResult::NotMyToken;
42        }
43
44        // Parse the token payload
45        let payload = match parse_token(token) {
46            Ok(p) => p,
47            Err(e) => return ValidationResult::Invalid(format!("malformed entity token: {}", e)),
48        };
49
50        // Check token age if configured
51        if self.max_token_age > 0 {
52            let now = std::time::SystemTime::now()
53                .duration_since(std::time::UNIX_EPOCH)
54                .map(|d| d.as_secs())
55                .unwrap_or(0);
56
57            if now.saturating_sub(payload.timestamp) > self.max_token_age {
58                return ValidationResult::Expired;
59            }
60        }
61
62        // Look up entity -- we need to block on the async store
63        // Since TokenValidator::validate is sync, we use a thread-local runtime
64        // or assume we're called from within a tokio runtime
65        let store = self.store.clone();
66        let entity_id_str = payload.entity_id.clone();
67
68        let entity_id = match crate::entity::EntityId::parse(&entity_id_str) {
69            Ok(id) => id,
70            Err(e) => return ValidationResult::Invalid(format!("invalid entity ID: {}", e)),
71        };
72
73        // Use tokio::task::block_in_place to call async from sync context
74        let entity: Entity = match tokio::task::block_in_place(|| {
75            tokio::runtime::Handle::current().block_on(store.get(&entity_id))
76        }) {
77            Ok(Some(e)) => e,
78            Ok(None) => {
79                return ValidationResult::Invalid(format!("entity not found: {}", entity_id_str))
80            }
81            Err(e) => return ValidationResult::Invalid(format!("store error: {}", e)),
82        };
83
84        // Check entity status
85        if !entity.is_active() {
86            return ValidationResult::Invalid(format!(
87                "entity {} is {}",
88                entity_id_str, entity.status
89            ));
90        }
91
92        // Verify signature
93        if let Err(e) = verify_token_signature(&payload, &entity.public_key) {
94            return ValidationResult::Invalid(format!("signature error: {}", e));
95        }
96
97        // Build scopes from entity
98        let scopes: Vec<Scope> = entity
99            .scopes
100            .iter()
101            .filter_map(|s| Scope::parse(s).ok())
102            .collect();
103
104        // If no scopes defined, grant based on namespaces
105        let scopes = if scopes.is_empty() && !entity.namespaces.is_empty() {
106            entity
107                .namespaces
108                .iter()
109                .filter_map(|ns: &String| {
110                    let pattern = if ns.ends_with("/**") {
111                        ns.clone()
112                    } else if ns.ends_with('/') {
113                        format!("{}**", ns)
114                    } else {
115                        format!("{}/**", ns)
116                    };
117                    Scope::parse(&format!("admin:{}", pattern)).ok()
118                })
119                .collect()
120        } else {
121            scopes
122        };
123
124        let info = TokenInfo::new(token.to_string(), scopes)
125            .with_subject(entity_id_str)
126            .with_metadata("entity_type", entity.entity_type.to_string())
127            .with_metadata("entity_name", entity.name);
128
129        ValidationResult::Valid(info)
130    }
131
132    fn name(&self) -> &str {
133        "Entity"
134    }
135
136    fn as_any(&self) -> &dyn std::any::Any {
137        self
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::entity::{EntityKeypair, EntityType};
145    use crate::store::MemoryEntityStore;
146    use crate::token::generate_token;
147
148    #[tokio::test(flavor = "multi_thread")]
149    async fn test_entity_validator_valid() {
150        let store = Arc::new(MemoryEntityStore::new());
151        let keypair = EntityKeypair::generate().unwrap();
152        let mut entity = keypair.to_entity(EntityType::Device, "test-device".to_string());
153        entity.scopes = vec!["admin:/**".to_string()];
154        store.create(&entity).await.unwrap();
155
156        let validator = EntityValidator::new(store);
157        let token = generate_token(&keypair).unwrap();
158
159        match validator.validate(&token) {
160            ValidationResult::Valid(info) => {
161                assert!(info.has_scope(clasp_core::Action::Read, "/any/path"));
162                assert_eq!(info.subject.as_deref(), Some(keypair.entity_id.as_str()));
163            }
164            other => panic!("expected Valid, got {:?}", other),
165        }
166    }
167
168    #[tokio::test(flavor = "multi_thread")]
169    async fn test_entity_validator_not_my_token() {
170        let store = Arc::new(MemoryEntityStore::new());
171        let validator = EntityValidator::new(store);
172
173        match validator.validate("cpsk_some_token") {
174            ValidationResult::NotMyToken => {}
175            other => panic!("expected NotMyToken, got {:?}", other),
176        }
177    }
178
179    #[tokio::test(flavor = "multi_thread")]
180    async fn test_entity_validator_unknown_entity() {
181        let store = Arc::new(MemoryEntityStore::new());
182        let validator = EntityValidator::new(store);
183        let keypair = EntityKeypair::generate().unwrap();
184        let token = generate_token(&keypair).unwrap();
185
186        match validator.validate(&token) {
187            ValidationResult::Invalid(msg) => {
188                assert!(msg.contains("not found"));
189            }
190            other => panic!("expected Invalid, got {:?}", other),
191        }
192    }
193
194    #[tokio::test(flavor = "multi_thread")]
195    async fn test_entity_validator_wrong_signature() {
196        let store = Arc::new(MemoryEntityStore::new());
197        let keypair = EntityKeypair::generate().unwrap();
198        let entity = keypair.to_entity(EntityType::Device, "test-device".to_string());
199        store.create(&entity).await.unwrap();
200
201        // Generate token with a different keypair
202        let other_keypair = EntityKeypair::generate().unwrap();
203        // Manually create a token that claims to be the first entity but signed by the second
204        let token = {
205            use base64::engine::general_purpose::URL_SAFE_NO_PAD;
206            use base64::Engine;
207            use ed25519_dalek::Signer;
208
209            let timestamp = 0u64;
210            let entity_id = keypair.entity_id.as_str().to_string();
211            let mut message = entity_id.as_bytes().to_vec();
212            message.extend_from_slice(&timestamp.to_be_bytes());
213
214            let signature = other_keypair.signing_key.sign(&message);
215
216            let payload = crate::token::EntityTokenPayload {
217                entity_id,
218                timestamp,
219                signature: signature.to_bytes().to_vec(),
220            };
221
222            let encoded = rmp_serde::to_vec(&payload).unwrap();
223            format!("ent_{}", URL_SAFE_NO_PAD.encode(&encoded))
224        };
225
226        let validator = EntityValidator::new(store);
227        match validator.validate(&token) {
228            ValidationResult::Invalid(msg) => {
229                assert!(msg.contains("signature"));
230            }
231            other => panic!("expected Invalid, got {:?}", other),
232        }
233    }
234
235    #[tokio::test(flavor = "multi_thread")]
236    async fn test_entity_validator_suspended() {
237        let store = Arc::new(MemoryEntityStore::new());
238        let keypair = EntityKeypair::generate().unwrap();
239        let mut entity = keypair.to_entity(EntityType::Device, "test-device".to_string());
240        entity.status = crate::entity::EntityStatus::Suspended;
241        store.create(&entity).await.unwrap();
242
243        let validator = EntityValidator::new(store);
244        let token = generate_token(&keypair).unwrap();
245
246        match validator.validate(&token) {
247            ValidationResult::Invalid(msg) => {
248                assert!(msg.contains("suspended"));
249            }
250            other => panic!("expected Invalid, got {:?}", other),
251        }
252    }
253
254    #[tokio::test(flavor = "multi_thread")]
255    async fn test_entity_validator_namespace_scopes() {
256        let store = Arc::new(MemoryEntityStore::new());
257        let keypair = EntityKeypair::generate().unwrap();
258        let mut entity = keypair.to_entity(EntityType::Device, "light-controller".to_string());
259        entity.namespaces = vec!["/lights".to_string()];
260        store.create(&entity).await.unwrap();
261
262        let validator = EntityValidator::new(store);
263        let token = generate_token(&keypair).unwrap();
264
265        match validator.validate(&token) {
266            ValidationResult::Valid(info) => {
267                assert!(info.has_scope(clasp_core::Action::Admin, "/lights/room1"));
268                assert!(!info.has_scope(clasp_core::Action::Read, "/audio/mixer"));
269            }
270            other => panic!("expected Valid, got {:?}", other),
271        }
272    }
273
274    // --- Negative tests ---
275
276    #[tokio::test(flavor = "multi_thread")]
277    async fn test_entity_validator_malformed_token() {
278        let store = Arc::new(MemoryEntityStore::new());
279        let validator = EntityValidator::new(store);
280
281        // Bad base64 after prefix
282        match validator.validate("ent_!!!invalid!!!") {
283            ValidationResult::Invalid(msg) => {
284                assert!(
285                    msg.contains("malformed"),
286                    "expected malformed error, got: {}",
287                    msg
288                );
289            }
290            other => panic!("expected Invalid, got {:?}", other),
291        }
292    }
293
294    #[tokio::test(flavor = "multi_thread")]
295    async fn test_entity_validator_revoked_entity() {
296        let store = Arc::new(MemoryEntityStore::new());
297        let keypair = EntityKeypair::generate().unwrap();
298        let mut entity = keypair.to_entity(EntityType::Device, "test-device".to_string());
299        entity.status = crate::entity::EntityStatus::Revoked;
300        store.create(&entity).await.unwrap();
301
302        let validator = EntityValidator::new(store);
303        let token = generate_token(&keypair).unwrap();
304
305        match validator.validate(&token) {
306            ValidationResult::Invalid(msg) => {
307                assert!(
308                    msg.contains("revoked"),
309                    "expected revoked error, got: {}",
310                    msg
311                );
312            }
313            other => panic!("expected Invalid, got {:?}", other),
314        }
315    }
316
317    #[tokio::test(flavor = "multi_thread")]
318    async fn test_entity_validator_max_token_age() {
319        let store = Arc::new(MemoryEntityStore::new());
320        let keypair = EntityKeypair::generate().unwrap();
321        let entity = keypair.to_entity(EntityType::Device, "test-device".to_string());
322        store.create(&entity).await.unwrap();
323
324        // Validator with 0 max age (rejects everything except brand-new tokens)
325        let validator = EntityValidator::new(store).with_max_token_age(1);
326
327        // Generate a token, then wait briefly so it ages past the 1-second limit
328        // Instead of waiting, we can create a token with an old timestamp manually
329        {
330            use base64::engine::general_purpose::URL_SAFE_NO_PAD;
331            use base64::Engine;
332            use ed25519_dalek::Signer;
333
334            let old_timestamp = 1000u64; // way in the past
335            let entity_id = keypair.entity_id.as_str().to_string();
336            let mut message = entity_id.as_bytes().to_vec();
337            message.extend_from_slice(&old_timestamp.to_be_bytes());
338
339            let signature = keypair.signing_key.sign(&message);
340
341            let payload = crate::token::EntityTokenPayload {
342                entity_id,
343                timestamp: old_timestamp,
344                signature: signature.to_bytes().to_vec(),
345            };
346
347            let encoded = rmp_serde::to_vec(&payload).unwrap();
348            let token = format!("ent_{}", URL_SAFE_NO_PAD.encode(&encoded));
349
350            match validator.validate(&token) {
351                ValidationResult::Expired => {}
352                other => panic!("expected Expired, got {:?}", other),
353            }
354        }
355    }
356
357    #[tokio::test(flavor = "multi_thread")]
358    async fn test_entity_validator_nonexistent_entity() {
359        let store = Arc::new(MemoryEntityStore::new());
360        let validator = EntityValidator::new(store);
361
362        // Generate a token for a keypair that was never registered
363        let keypair = EntityKeypair::generate().unwrap();
364        let token = generate_token(&keypair).unwrap();
365
366        match validator.validate(&token) {
367            ValidationResult::Invalid(msg) => {
368                assert!(
369                    msg.contains("not found"),
370                    "expected not found error, got: {}",
371                    msg
372                );
373            }
374            other => panic!("expected Invalid, got {:?}", other),
375        }
376    }
377}