Skip to main content

clawft_kernel/
auth_service.rs

1//! Centralized credential management service -- Plan 9 Factotum pattern (K5-G2).
2//!
3//! The [`AuthService`] manages external credentials centrally so that agents
4//! never hold raw secrets. Instead, agents request scoped, time-limited tokens
5//! via IPC. All credential access is audited.
6
7#[cfg(feature = "exochain")]
8use std::sync::Arc;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::Duration;
11
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use dashmap::DashMap;
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use tracing::{info, warn};
18
19use crate::error::KernelError;
20use crate::health::HealthStatus;
21use crate::process::Pid;
22use crate::service::{ServiceType, SystemService};
23
24#[cfg(feature = "exochain")]
25use crate::chain::ChainManager;
26#[cfg(feature = "exochain")]
27use crate::gate::GateBackend;
28
29// ---------------------------------------------------------------------------
30// CredentialType
31// ---------------------------------------------------------------------------
32
33/// Classification of a stored credential.
34#[non_exhaustive]
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub enum CredentialType {
37    /// API key (e.g., OpenAI, GitHub).
38    ApiKey,
39    /// Bearer/OAuth token.
40    BearerToken,
41    /// TLS client certificate.
42    Certificate,
43    /// User-defined credential type.
44    Custom(String),
45}
46
47impl std::fmt::Display for CredentialType {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::ApiKey => write!(f, "api_key"),
51            Self::BearerToken => write!(f, "bearer_token"),
52            Self::Certificate => write!(f, "certificate"),
53            Self::Custom(s) => write!(f, "custom({s})"),
54        }
55    }
56}
57
58// ---------------------------------------------------------------------------
59// StoredCredential
60// ---------------------------------------------------------------------------
61
62/// An encrypted credential stored by the AuthService.
63#[derive(Debug, Clone)]
64pub struct StoredCredential {
65    /// Human-readable credential name.
66    pub name: String,
67    /// Credential classification.
68    pub credential_type: CredentialType,
69    /// Encrypted credential value (never exposed directly).
70    encrypted_value: Vec<u8>,
71    /// Agent IDs allowed to request tokens for this credential.
72    pub allowed_agents: Vec<String>,
73    /// When the credential was registered.
74    pub created_at: DateTime<Utc>,
75}
76
77// ---------------------------------------------------------------------------
78// IssuedToken
79// ---------------------------------------------------------------------------
80
81/// A scoped, time-limited token issued to an agent.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct IssuedToken {
84    /// Unique token identifier.
85    pub token_id: String,
86    /// Name of the credential this token grants access to.
87    pub credential_name: String,
88    /// PID of the agent the token was issued to.
89    pub issued_to: Pid,
90    /// When the token was issued.
91    pub issued_at: DateTime<Utc>,
92    /// When the token expires.
93    pub expires_at: DateTime<Utc>,
94    /// Scoped operations this token permits.
95    pub scope: Vec<String>,
96}
97
98impl IssuedToken {
99    /// Check whether the token has expired.
100    pub fn is_expired(&self) -> bool {
101        Utc::now() > self.expires_at
102    }
103}
104
105// ---------------------------------------------------------------------------
106// CredentialRequest / CredentialGrant
107// ---------------------------------------------------------------------------
108
109/// Request from an agent to obtain a credential token.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct CredentialRequest {
112    /// Name of the credential to access.
113    pub credential_name: String,
114    /// PID of the requesting agent.
115    pub requester_pid: Pid,
116    /// Agent ID for authorization check.
117    pub agent_id: String,
118    /// Requested operations scope.
119    pub scope: Vec<String>,
120    /// Requested time-to-live.
121    pub ttl_secs: u64,
122}
123
124/// Response granting (or denying) a credential request.
125#[non_exhaustive]
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub enum CredentialGrant {
128    /// Token granted.
129    Granted(IssuedToken),
130    /// Request denied.
131    Denied { reason: String },
132}
133
134// ---------------------------------------------------------------------------
135// AuthService
136// ---------------------------------------------------------------------------
137
138// ---------------------------------------------------------------------------
139// HashedCredential — SHA-256 hashed agent credentials
140// ---------------------------------------------------------------------------
141
142/// A hashed credential for agent authentication.
143///
144/// Raw credentials are **never** stored. Only the SHA-256 hash is kept.
145#[derive(Debug, Clone)]
146pub struct HashedCredential {
147    /// Agent identity this credential belongs to.
148    pub agent_id: String,
149    /// SHA-256 hash of the raw credential.
150    pub hash: Vec<u8>,
151    /// When the credential was created.
152    pub created_at: DateTime<Utc>,
153    /// Scopes this credential grants.
154    pub scopes: Vec<String>,
155}
156
157/// A scoped authentication token issued after successful authentication.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct AuthToken {
160    /// Unique token identifier.
161    pub token_id: String,
162    /// Agent identity this token was issued to.
163    pub agent_id: String,
164    /// Scopes granted by this token.
165    pub scopes: Vec<String>,
166    /// When the token expires.
167    pub expires_at: DateTime<Utc>,
168    /// When the token was issued.
169    pub created_at: DateTime<Utc>,
170}
171
172impl AuthToken {
173    /// Check whether the token has expired.
174    pub fn is_expired(&self) -> bool {
175        Utc::now() > self.expires_at
176    }
177}
178
179// ---------------------------------------------------------------------------
180// AuthService
181// ---------------------------------------------------------------------------
182
183/// Centralized credential management service (Factotum pattern).
184///
185/// - Credentials are registered once, encrypted at rest.
186/// - Agents request scoped tokens; raw credentials are never exposed.
187/// - Token issuance and access are audited.
188/// - SHA-256 hashed credentials support agent authentication.
189pub struct AuthService {
190    /// Registered credentials (encrypted, name-based).
191    credentials: DashMap<String, StoredCredential>,
192    /// SHA-256 hashed credentials (agent-id-based).
193    hashed_credentials: DashMap<String, HashedCredential>,
194    /// Active tokens (token-based credential access).
195    active_tokens: DashMap<String, IssuedToken>,
196    /// Active auth tokens (from `authenticate`).
197    auth_tokens: DashMap<String, AuthToken>,
198    /// Audit log.
199    audit_log: std::sync::RwLock<Vec<AuditEntry>>,
200    /// Encryption key for credentials.
201    encryption_key: [u8; 32],
202    /// Monotonic token counter.
203    token_counter: AtomicU64,
204    /// ExoChain manager for event logging.
205    #[cfg(feature = "exochain")]
206    chain_manager: Option<Arc<ChainManager>>,
207    /// Governance gate for access control.
208    #[cfg(feature = "exochain")]
209    governance_gate: Option<Arc<dyn GateBackend>>,
210}
211
212/// An audit log entry.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct AuditEntry {
215    /// What happened.
216    pub action: String,
217    /// Who did it.
218    pub agent_id: String,
219    /// Credential involved.
220    pub credential_name: String,
221    /// When it happened.
222    pub timestamp: DateTime<Utc>,
223    /// Whether it was allowed.
224    pub allowed: bool,
225}
226
227impl AuthService {
228    /// Create a new AuthService with the given encryption key.
229    pub fn new(encryption_key: [u8; 32]) -> Self {
230        Self {
231            credentials: DashMap::new(),
232            hashed_credentials: DashMap::new(),
233            active_tokens: DashMap::new(),
234            auth_tokens: DashMap::new(),
235            audit_log: std::sync::RwLock::new(Vec::new()),
236            encryption_key,
237            token_counter: AtomicU64::new(0),
238            #[cfg(feature = "exochain")]
239            chain_manager: None,
240            #[cfg(feature = "exochain")]
241            governance_gate: None,
242        }
243    }
244
245    /// Create with a default (zero) encryption key (testing only).
246    pub fn new_default() -> Self {
247        Self::new([0u8; 32])
248    }
249
250    /// Set the chain manager for exochain event logging.
251    #[cfg(feature = "exochain")]
252    pub fn with_chain_manager(mut self, cm: Arc<ChainManager>) -> Self {
253        self.chain_manager = Some(cm);
254        self
255    }
256
257    /// Set the governance gate for access control.
258    #[cfg(feature = "exochain")]
259    pub fn with_governance_gate(mut self, gate: Arc<dyn GateBackend>) -> Self {
260        self.governance_gate = Some(gate);
261        self
262    }
263
264    // ── Credential registration ───────────────────────────────────
265
266    /// Register a new credential.
267    pub fn register_credential(
268        &self,
269        name: &str,
270        credential_type: CredentialType,
271        value: &[u8],
272        allowed_agents: Vec<String>,
273    ) -> Result<(), KernelError> {
274        // Governance gate: credential registration is a critical action.
275        #[cfg(feature = "exochain")]
276        if let Some(ref gate) = self.governance_gate {
277            use crate::gate::GateDecision;
278            use crate::governance::{GateEffectKind, with_effect_context};
279            let ctx = with_effect_context(
280                serde_json::json!({
281                    "credential_name": name,
282                    "credential_type": credential_type.to_string(),
283                }),
284                &GateEffectKind::AuthCredentialRegister.effect_vector(),
285            );
286            let decision = gate.check("auth-service", "auth.credential.register", &ctx);
287            if let GateDecision::Deny { reason, .. } = decision {
288                return Err(KernelError::GovernanceDenied(format!(
289                    "credential registration denied: {reason}"
290                )));
291            }
292        }
293
294        if self.credentials.contains_key(name) {
295            return Err(KernelError::Service(format!(
296                "credential already registered: {name}"
297            )));
298        }
299
300        let encrypted = self.xor_encrypt(value);
301        #[cfg(feature = "exochain")]
302        let cred_type_for_chain = credential_type.to_string();
303        self.credentials.insert(
304            name.to_string(),
305            StoredCredential {
306                name: name.to_string(),
307                credential_type,
308                encrypted_value: encrypted,
309                allowed_agents,
310                created_at: Utc::now(),
311            },
312        );
313
314        #[cfg(feature = "exochain")]
315        if let Some(ref cm) = self.chain_manager {
316            cm.append(
317                "auth",
318                crate::chain::EVENT_KIND_AUTH_CREDENTIAL_REGISTER,
319                Some(serde_json::json!({
320                    "credential_name": name,
321                    "credential_type": cred_type_for_chain,
322                })),
323            );
324        }
325
326        info!(name, "credential registered");
327        Ok(())
328    }
329
330    /// Update an existing credential's value (rotation).
331    pub fn rotate_credential(&self, name: &str, new_value: &[u8]) -> Result<(), KernelError> {
332        // Governance gate: credential rotation is a critical security action.
333        #[cfg(feature = "exochain")]
334        if let Some(ref gate) = self.governance_gate {
335            use crate::gate::GateDecision;
336            use crate::governance::{GateEffectKind, with_effect_context};
337            let ctx = with_effect_context(
338                serde_json::json!({
339                    "credential_name": name,
340                }),
341                &GateEffectKind::AuthCredentialRotate.effect_vector(),
342            );
343            let decision = gate.check("auth-service", "auth.credential.rotate", &ctx);
344            if let GateDecision::Deny { reason, .. } = decision {
345                return Err(KernelError::GovernanceDenied(format!(
346                    "credential rotation denied: {reason}"
347                )));
348            }
349        }
350
351        let mut cred = self
352            .credentials
353            .get_mut(name)
354            .ok_or_else(|| KernelError::Service(format!("credential not found: {name}")))?;
355        cred.encrypted_value = self.xor_encrypt(new_value);
356
357        #[cfg(feature = "exochain")]
358        if let Some(ref cm) = self.chain_manager {
359            cm.append(
360                "auth",
361                crate::chain::EVENT_KIND_AUTH_CREDENTIAL_ROTATE,
362                Some(serde_json::json!({
363                    "credential_name": name,
364                })),
365            );
366        }
367
368        info!(name, "credential rotated");
369        Ok(())
370    }
371
372    // ── Token issuance ────────────────────────────────────────────
373
374    /// Request a scoped, time-limited token.
375    pub fn request_token(&self, request: &CredentialRequest) -> Result<IssuedToken, KernelError> {
376        // Governance gate: token issuance is governed by policy.
377        #[cfg(feature = "exochain")]
378        if let Some(ref gate) = self.governance_gate {
379            use crate::gate::GateDecision;
380            use crate::governance::{GateEffectKind, with_effect_context};
381            let ctx = with_effect_context(
382                serde_json::json!({
383                    "credential_name": &request.credential_name,
384                    "agent_id": &request.agent_id,
385                    "ttl_secs": request.ttl_secs,
386                }),
387                &GateEffectKind::AuthTokenIssue.effect_vector(),
388            );
389            let decision = gate.check("auth-service", "auth.token.issue", &ctx);
390            if let GateDecision::Deny { reason, .. } = decision {
391                return Err(KernelError::GovernanceDenied(format!(
392                    "token issuance denied: {reason}"
393                )));
394            }
395        }
396
397        let cred = self
398            .credentials
399            .get(&request.credential_name)
400            .ok_or_else(|| {
401                KernelError::Service(format!("credential not found: {}", request.credential_name))
402            })?;
403
404        // Authorization check.
405        if !cred.allowed_agents.is_empty() && !cred.allowed_agents.contains(&request.agent_id) {
406            self.audit(
407                "token.denied",
408                &request.agent_id,
409                &request.credential_name,
410                false,
411            );
412            warn!(
413                agent_id = %request.agent_id,
414                credential = %request.credential_name,
415                "token request denied"
416            );
417            return Err(KernelError::CapabilityDenied {
418                pid: request.requester_pid,
419                action: "request_token".into(),
420                reason: format!(
421                    "agent '{}' not authorized for credential '{}'",
422                    request.agent_id, request.credential_name
423                ),
424            });
425        }
426
427        let ttl = Duration::from_secs(request.ttl_secs.max(1));
428        let token = IssuedToken {
429            token_id: uuid::Uuid::new_v4().to_string(),
430            credential_name: request.credential_name.clone(),
431            issued_to: request.requester_pid,
432            issued_at: Utc::now(),
433            expires_at: Utc::now()
434                + chrono::Duration::from_std(ttl).unwrap_or(chrono::Duration::hours(1)),
435            scope: request.scope.clone(),
436        };
437
438        self.active_tokens
439            .insert(token.token_id.clone(), token.clone());
440        self.audit(
441            "token.issued",
442            &request.agent_id,
443            &request.credential_name,
444            true,
445        );
446
447        #[cfg(feature = "exochain")]
448        if let Some(ref cm) = self.chain_manager {
449            cm.append(
450                "auth",
451                crate::chain::EVENT_KIND_AUTH_TOKEN_ISSUE,
452                Some(serde_json::json!({
453                    "token_id": token.token_id,
454                    "credential_name": request.credential_name,
455                    "agent_id": request.agent_id,
456                    "ttl_secs": request.ttl_secs,
457                })),
458            );
459        }
460
461        info!(
462            token_id = %token.token_id,
463            credential = %request.credential_name,
464            agent = %request.agent_id,
465            ttl_secs = request.ttl_secs,
466            "token issued"
467        );
468        Ok(token)
469    }
470
471    /// Validate an issued token. Returns `Err` if expired or not found.
472    pub fn validate_token(&self, token_id: &str) -> Result<IssuedToken, KernelError> {
473        let token = self
474            .active_tokens
475            .get(token_id)
476            .ok_or_else(|| KernelError::Service("token not found".into()))?;
477
478        if token.is_expired() {
479            return Err(KernelError::Service("token expired".into()));
480        }
481
482        Ok(token.clone())
483    }
484
485    /// Revoke an active token.
486    ///
487    /// Returns `Ok(true)` if a token was revoked, `Ok(false)` if the
488    /// token was not present (idempotent re-revoke), and
489    /// `Err(KernelError::GovernanceDenied)` if the configured
490    /// governance gate denies the `auth.token.revoke` action.
491    ///
492    /// WEFT-98: revocation is a privileged operation -- it is the
493    /// only mechanism for invalidating an issued credential before
494    /// its TTL elapses. Without a governance gate, a compromised
495    /// caller could mass-revoke tokens to deny service. The gate
496    /// runs *before* state mutation so a denied revoke leaves the
497    /// active-token map untouched.
498    pub fn revoke_token(&self, token_id: &str) -> Result<bool, KernelError> {
499        // Governance gate: token revocation is governed by policy
500        // (mirrors the rotate_credential / request_token gates).
501        #[cfg(feature = "exochain")]
502        if let Some(ref gate) = self.governance_gate {
503            use crate::gate::GateDecision;
504            use crate::governance::{GateEffectKind, with_effect_context};
505            let ctx = with_effect_context(
506                serde_json::json!({
507                    "token_id": token_id,
508                }),
509                &GateEffectKind::AuthTokenRevoke.effect_vector(),
510            );
511            let decision = gate.check("auth-service", "auth.token.revoke", &ctx);
512            if let GateDecision::Deny { reason, .. } = decision {
513                return Err(KernelError::GovernanceDenied(format!(
514                    "token revocation denied: {reason}"
515                )));
516            }
517        }
518
519        let removed = self.active_tokens.remove(token_id).is_some();
520
521        #[cfg(feature = "exochain")]
522        if removed && let Some(ref cm) = self.chain_manager {
523            cm.append(
524                "auth",
525                crate::chain::EVENT_KIND_AUTH_TOKEN_REVOKE,
526                Some(serde_json::json!({
527                    "token_id": token_id,
528                })),
529            );
530        }
531
532        Ok(removed)
533    }
534
535    /// List all active (non-expired) tokens.
536    pub fn active_token_count(&self) -> usize {
537        self.active_tokens
538            .iter()
539            .filter(|t| !t.value().is_expired())
540            .count()
541    }
542
543    // ── SHA-256 hashed credential operations ─────────────────────
544
545    /// Compute the SHA-256 hash of a raw credential.
546    fn sha256_hash(data: &[u8]) -> Vec<u8> {
547        let mut hasher = Sha256::new();
548        hasher.update(data);
549        hasher.finalize().to_vec()
550    }
551
552    /// Register a hashed credential for an agent.
553    ///
554    /// The raw credential is **never stored**; only its SHA-256 hash is kept.
555    pub fn register_hashed_credential(
556        &self,
557        agent_id: &str,
558        raw_credential: &[u8],
559        scopes: Vec<String>,
560    ) -> Result<(), KernelError> {
561        // Governance gate: hashed credential registration is a critical action.
562        #[cfg(feature = "exochain")]
563        if let Some(ref gate) = self.governance_gate {
564            use crate::gate::GateDecision;
565            use crate::governance::{GateEffectKind, with_effect_context};
566            let ctx = with_effect_context(
567                serde_json::json!({
568                    "agent_id": agent_id,
569                }),
570                &GateEffectKind::AuthCredentialRegister.effect_vector(),
571            );
572            let decision = gate.check("auth-service", "auth.credential.register", &ctx);
573            if let GateDecision::Deny { reason, .. } = decision {
574                return Err(KernelError::GovernanceDenied(format!(
575                    "hashed credential registration denied: {reason}"
576                )));
577            }
578        }
579
580        if self.hashed_credentials.contains_key(agent_id) {
581            return Err(KernelError::Service(format!(
582                "hashed credential already registered for agent: {agent_id}"
583            )));
584        }
585
586        let hash = Self::sha256_hash(raw_credential);
587        self.hashed_credentials.insert(
588            agent_id.to_string(),
589            HashedCredential {
590                agent_id: agent_id.to_string(),
591                hash,
592                created_at: Utc::now(),
593                scopes,
594            },
595        );
596
597        #[cfg(feature = "exochain")]
598        if let Some(ref cm) = self.chain_manager {
599            cm.append(
600                "auth",
601                crate::chain::EVENT_KIND_AUTH_CREDENTIAL_REGISTER,
602                Some(serde_json::json!({
603                    "agent_id": agent_id,
604                    "type": "hashed",
605                })),
606            );
607        }
608
609        info!(agent_id, "hashed credential registered");
610        Ok(())
611    }
612
613    /// Authenticate an agent by verifying its raw credential against the stored hash.
614    ///
615    /// On success, issues a scoped [`AuthToken`] valid for one hour.
616    pub fn authenticate(
617        &self,
618        agent_id: &str,
619        raw_credential: &[u8],
620    ) -> Result<AuthToken, KernelError> {
621        let cred = self
622            .hashed_credentials
623            .get(agent_id)
624            .ok_or_else(|| KernelError::Service(format!("no credential for agent: {agent_id}")))?;
625
626        let provided_hash = Self::sha256_hash(raw_credential);
627        if cred.hash != provided_hash {
628            self.audit("authenticate.failed", agent_id, agent_id, false);
629
630            #[cfg(feature = "exochain")]
631            if let Some(ref cm) = self.chain_manager {
632                cm.append(
633                    "auth",
634                    crate::chain::EVENT_KIND_AUTH_ATTEMPT,
635                    Some(serde_json::json!({
636                        "agent_id": agent_id,
637                        "success": false,
638                    })),
639                );
640            }
641
642            warn!(agent_id, "authentication failed — hash mismatch");
643            return Err(KernelError::Service("authentication failed".into()));
644        }
645
646        let seq = self.token_counter.fetch_add(1, Ordering::Relaxed);
647        let token = AuthToken {
648            token_id: format!("auth-{}-{seq}", uuid::Uuid::new_v4()),
649            agent_id: agent_id.to_string(),
650            scopes: cred.scopes.clone(),
651            expires_at: Utc::now() + chrono::Duration::hours(1),
652            created_at: Utc::now(),
653        };
654
655        self.auth_tokens
656            .insert(token.token_id.clone(), token.clone());
657        self.audit("authenticate.success", agent_id, agent_id, true);
658
659        #[cfg(feature = "exochain")]
660        if let Some(ref cm) = self.chain_manager {
661            cm.append(
662                "auth",
663                crate::chain::EVENT_KIND_AUTH_ATTEMPT,
664                Some(serde_json::json!({
665                    "agent_id": agent_id,
666                    "success": true,
667                    "token_id": token.token_id,
668                })),
669            );
670        }
671
672        info!(agent_id, token_id = %token.token_id, "agent authenticated");
673        Ok(token)
674    }
675
676    /// Validate an auth token. Returns `Err` if expired or not found.
677    pub fn validate_auth_token(&self, token_id: &str) -> Result<AuthToken, KernelError> {
678        let token = self
679            .auth_tokens
680            .get(token_id)
681            .ok_or_else(|| KernelError::Service("auth token not found".into()))?;
682
683        if token.is_expired() {
684            return Err(KernelError::Service("auth token expired".into()));
685        }
686
687        Ok(token.clone())
688    }
689
690    /// Revoke an auth token. Returns `true` if it existed.
691    pub fn revoke_auth_token(&self, token_id: &str) -> bool {
692        self.auth_tokens.remove(token_id).is_some()
693    }
694
695    /// Check whether an auth token has a specific scope.
696    pub fn check_scope(&self, token_id: &str, required_scope: &str) -> bool {
697        self.auth_tokens
698            .get(token_id)
699            .map(|t| !t.is_expired() && t.scopes.contains(&required_scope.to_string()))
700            .unwrap_or(false)
701    }
702
703    // ── Audit ─────────────────────────────────────────────────────
704
705    fn audit(&self, action: &str, agent_id: &str, credential_name: &str, allowed: bool) {
706        if let Ok(mut log) = self.audit_log.write() {
707            log.push(AuditEntry {
708                action: action.to_string(),
709                agent_id: agent_id.to_string(),
710                credential_name: credential_name.to_string(),
711                timestamp: Utc::now(),
712                allowed,
713            });
714        }
715    }
716
717    /// Get the audit log.
718    pub fn audit_log(&self) -> Vec<AuditEntry> {
719        self.audit_log.read().map(|l| l.clone()).unwrap_or_default()
720    }
721
722    // ── Encryption ────────────────────────────────────────────────
723
724    fn xor_encrypt(&self, data: &[u8]) -> Vec<u8> {
725        data.iter()
726            .enumerate()
727            .map(|(i, b)| b ^ self.encryption_key[i % 32])
728            .collect()
729    }
730}
731
732#[async_trait]
733impl SystemService for AuthService {
734    fn name(&self) -> &str {
735        "auth-service"
736    }
737
738    fn service_type(&self) -> ServiceType {
739        ServiceType::Core
740    }
741
742    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
743        info!("auth service started");
744        Ok(())
745    }
746
747    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
748        info!(
749            credentials = self.credentials.len(),
750            active_tokens = self.active_token_count(),
751            "auth service stopped"
752        );
753        Ok(())
754    }
755
756    async fn health_check(&self) -> HealthStatus {
757        HealthStatus::Healthy
758    }
759}
760
761// ── Tests ─────────────────────────────────────────────────────────────────
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766
767    fn pid(n: u64) -> Pid {
768        n
769    }
770
771    fn make_request(cred: &str, agent: &str, pid_val: u64) -> CredentialRequest {
772        CredentialRequest {
773            credential_name: cred.to_string(),
774            requester_pid: pid(pid_val),
775            agent_id: agent.to_string(),
776            scope: vec!["read".to_string()],
777            ttl_secs: 3600,
778        }
779    }
780
781    #[test]
782    fn register_and_request_token() {
783        let svc = AuthService::new_default();
784        svc.register_credential(
785            "github-key",
786            CredentialType::ApiKey,
787            b"ghp_secret_value",
788            vec!["deploy-agent".to_string()],
789        )
790        .unwrap();
791
792        let req = make_request("github-key", "deploy-agent", 1);
793        let token = svc.request_token(&req).unwrap();
794        assert!(!token.token_id.is_empty());
795        assert_eq!(token.credential_name, "github-key");
796    }
797
798    #[test]
799    fn unauthorized_agent_denied() {
800        let svc = AuthService::new_default();
801        svc.register_credential(
802            "secret",
803            CredentialType::BearerToken,
804            b"token",
805            vec!["allowed-agent".to_string()],
806        )
807        .unwrap();
808
809        let req = make_request("secret", "other-agent", 2);
810        let result = svc.request_token(&req);
811        assert!(result.is_err());
812        let err = result.unwrap_err().to_string();
813        assert!(
814            err.contains("denied") || err.contains("authorized"),
815            "got: {err}"
816        );
817    }
818
819    #[test]
820    fn empty_allowed_agents_permits_all() {
821        let svc = AuthService::new_default();
822        svc.register_credential(
823            "open-cred",
824            CredentialType::ApiKey,
825            b"value",
826            vec![], // any agent
827        )
828        .unwrap();
829
830        let req = make_request("open-cred", "random-agent", 5);
831        let token = svc.request_token(&req).unwrap();
832        assert!(!token.is_expired());
833    }
834
835    #[test]
836    fn validate_token() {
837        let svc = AuthService::new_default();
838        svc.register_credential("cred", CredentialType::ApiKey, b"val", vec![])
839            .unwrap();
840        let req = make_request("cred", "agent", 1);
841        let token = svc.request_token(&req).unwrap();
842        let validated = svc.validate_token(&token.token_id).unwrap();
843        assert_eq!(validated.token_id, token.token_id);
844    }
845
846    #[test]
847    fn validate_nonexistent_token_fails() {
848        let svc = AuthService::new_default();
849        assert!(svc.validate_token("no-such-token").is_err());
850    }
851
852    #[test]
853    fn revoke_token() {
854        let svc = AuthService::new_default();
855        svc.register_credential("cred", CredentialType::ApiKey, b"val", vec![])
856            .unwrap();
857        let req = make_request("cred", "agent", 1);
858        let token = svc.request_token(&req).unwrap();
859        assert!(svc.revoke_token(&token.token_id).unwrap());
860        assert!(svc.validate_token(&token.token_id).is_err());
861        // Revoking an unknown / already-revoked token is idempotent.
862        assert!(!svc.revoke_token(&token.token_id).unwrap());
863    }
864
865    /// WEFT-98: revoke_token must consult the governance gate and
866    /// surface a `GovernanceDenied` error when the gate denies the
867    /// `auth.token.revoke` action.
868    ///
869    /// Uses a selective gate that only denies `auth.token.revoke`
870    /// so register/issue still succeed during setup.
871    #[cfg(feature = "exochain")]
872    #[test]
873    fn revoke_token_denied_by_governance() {
874        use crate::gate::{GateBackend, GateDecision};
875        use std::sync::Arc;
876
877        struct RevokeDenyGate;
878        impl GateBackend for RevokeDenyGate {
879            fn check(&self, _source: &str, action: &str, _ctx: &serde_json::Value) -> GateDecision {
880                if action == "auth.token.revoke" {
881                    GateDecision::Deny {
882                        reason: "policy: revoke disabled".into(),
883                        receipt: None,
884                    }
885                } else {
886                    GateDecision::Permit { token: None }
887                }
888            }
889        }
890
891        let svc = AuthService::new_default()
892            .with_governance_gate(Arc::new(RevokeDenyGate) as Arc<dyn GateBackend>);
893        svc.register_credential("cred", CredentialType::ApiKey, b"val", vec![])
894            .unwrap();
895        let req = make_request("cred", "agent", 1);
896        let token = svc.request_token(&req).unwrap();
897
898        let err = svc.revoke_token(&token.token_id).unwrap_err();
899        match err {
900            KernelError::GovernanceDenied(msg) => {
901                assert!(msg.contains("token revocation denied"), "got: {msg}");
902            }
903            other => panic!("expected GovernanceDenied, got {other:?}"),
904        }
905
906        // Token must still be valid -- a denied revoke is a no-op
907        // on the active-token map.
908        assert!(svc.validate_token(&token.token_id).is_ok());
909    }
910
911    /// WEFT-544: canonical "may rotate but not revoke" policy —
912    /// separate gate actions `auth.credential.rotate` (allow) vs
913    /// `auth.token.revoke` (deny). Existing DSL is sufficient; no new
914    /// construct. See docs/plans/decisions/0.8-decision-batch-open-questions.md.
915    #[cfg(feature = "exochain")]
916    #[test]
917    fn rotate_allowed_revoke_denied_policy() {
918        use crate::gate::{GateBackend, GateDecision};
919        use std::sync::Arc;
920
921        struct RotateAllowRevokeDeny;
922        impl GateBackend for RotateAllowRevokeDeny {
923            fn check(&self, _source: &str, action: &str, _ctx: &serde_json::Value) -> GateDecision {
924                if action == "auth.token.revoke" {
925                    GateDecision::Deny {
926                        reason: "policy: agent may rotate but not revoke".into(),
927                        receipt: None,
928                    }
929                } else {
930                    // auth.credential.rotate, auth.token.issue, register, …
931                    GateDecision::Permit { token: None }
932                }
933            }
934        }
935
936        let svc = AuthService::new_default()
937            .with_governance_gate(Arc::new(RotateAllowRevokeDeny) as Arc<dyn GateBackend>);
938        svc.register_credential("cred", CredentialType::ApiKey, b"old", vec![])
939            .unwrap();
940        let req = make_request("cred", "agent", 1);
941        let token = svc.request_token(&req).unwrap();
942
943        // Rotate succeeds under this policy.
944        svc.rotate_credential("cred", b"new").unwrap();
945
946        // Revoke is denied; token remains valid.
947        let err = svc.revoke_token(&token.token_id).unwrap_err();
948        match err {
949            KernelError::GovernanceDenied(msg) => {
950                assert!(msg.contains("token revocation denied"), "got: {msg}");
951            }
952            other => panic!("expected GovernanceDenied, got {other:?}"),
953        }
954        assert!(svc.validate_token(&token.token_id).is_ok());
955    }
956
957    #[test]
958    fn credential_rotation_preserves_tokens() {
959        let svc = AuthService::new_default();
960        svc.register_credential("rotate-cred", CredentialType::ApiKey, b"old_val", vec![])
961            .unwrap();
962        let req = make_request("rotate-cred", "agent", 1);
963        let token = svc.request_token(&req).unwrap();
964
965        // Rotate credential.
966        svc.rotate_credential("rotate-cred", b"new_val").unwrap();
967
968        // Existing token still valid.
969        let validated = svc.validate_token(&token.token_id).unwrap();
970        assert_eq!(validated.credential_name, "rotate-cred");
971    }
972
973    #[test]
974    fn raw_credential_never_exposed() {
975        let key = [0xAB; 32];
976        let svc = AuthService::new(key);
977        svc.register_credential("secret", CredentialType::ApiKey, b"raw_secret", vec![])
978            .unwrap();
979
980        // The stored value should be encrypted, not raw.
981        let cred = svc.credentials.get("secret").unwrap();
982        assert_ne!(cred.encrypted_value, b"raw_secret");
983    }
984
985    #[test]
986    fn audit_log_records_events() {
987        let svc = AuthService::new_default();
988        svc.register_credential(
989            "audited",
990            CredentialType::ApiKey,
991            b"val",
992            vec!["agent-a".to_string()],
993        )
994        .unwrap();
995
996        // Successful request.
997        let req = make_request("audited", "agent-a", 1);
998        svc.request_token(&req).unwrap();
999
1000        // Failed request.
1001        let bad_req = make_request("audited", "agent-b", 2);
1002        let _ = svc.request_token(&bad_req);
1003
1004        let log = svc.audit_log();
1005        assert_eq!(log.len(), 2);
1006        assert!(log[0].allowed);
1007        assert!(!log[1].allowed);
1008    }
1009
1010    #[test]
1011    fn duplicate_credential_registration_fails() {
1012        let svc = AuthService::new_default();
1013        svc.register_credential("dup", CredentialType::ApiKey, b"val", vec![])
1014            .unwrap();
1015        let result = svc.register_credential("dup", CredentialType::ApiKey, b"val2", vec![]);
1016        assert!(result.is_err());
1017    }
1018
1019    #[test]
1020    fn request_nonexistent_credential_fails() {
1021        let svc = AuthService::new_default();
1022        let req = make_request("missing", "agent", 1);
1023        assert!(svc.request_token(&req).is_err());
1024    }
1025
1026    #[tokio::test]
1027    async fn system_service_impl() {
1028        let svc = AuthService::new_default();
1029        assert_eq!(svc.name(), "auth-service");
1030        assert_eq!(svc.service_type(), ServiceType::Core);
1031        svc.start().await.unwrap();
1032        assert_eq!(svc.health_check().await, HealthStatus::Healthy);
1033        svc.stop().await.unwrap();
1034    }
1035
1036    // ── SHA-256 hashed credential tests ──────────────────────────
1037
1038    #[test]
1039    fn register_and_authenticate_success() {
1040        let svc = AuthService::new_default();
1041        svc.register_hashed_credential("agent-1", b"my_password", vec!["read".into()])
1042            .unwrap();
1043        let token = svc.authenticate("agent-1", b"my_password").unwrap();
1044        assert_eq!(token.agent_id, "agent-1");
1045        assert!(!token.token_id.is_empty());
1046        assert_eq!(token.scopes, vec!["read".to_string()]);
1047    }
1048
1049    #[test]
1050    fn authenticate_wrong_credential_fails() {
1051        let svc = AuthService::new_default();
1052        svc.register_hashed_credential("agent-2", b"correct", vec![])
1053            .unwrap();
1054        let result = svc.authenticate("agent-2", b"wrong");
1055        assert!(result.is_err());
1056        let err = result.unwrap_err().to_string();
1057        assert!(err.contains("failed"), "got: {err}");
1058    }
1059
1060    #[test]
1061    fn authenticate_issues_token_with_scopes() {
1062        let svc = AuthService::new_default();
1063        svc.register_hashed_credential(
1064            "scoped-agent",
1065            b"secret",
1066            vec!["read".into(), "write".into()],
1067        )
1068        .unwrap();
1069        let token = svc.authenticate("scoped-agent", b"secret").unwrap();
1070        assert_eq!(token.scopes, vec!["read".to_string(), "write".to_string()]);
1071    }
1072
1073    #[test]
1074    fn validate_auth_token_succeeds() {
1075        let svc = AuthService::new_default();
1076        svc.register_hashed_credential("v-agent", b"pass", vec![])
1077            .unwrap();
1078        let token = svc.authenticate("v-agent", b"pass").unwrap();
1079        let validated = svc.validate_auth_token(&token.token_id).unwrap();
1080        assert_eq!(validated.agent_id, "v-agent");
1081    }
1082
1083    #[test]
1084    fn validate_expired_auth_token_fails() {
1085        let svc = AuthService::new_default();
1086        svc.register_hashed_credential("exp-agent", b"pass", vec![])
1087            .unwrap();
1088        let token = svc.authenticate("exp-agent", b"pass").unwrap();
1089
1090        // Manually insert an expired token.
1091        let expired = AuthToken {
1092            token_id: "expired-tok".to_string(),
1093            agent_id: "exp-agent".to_string(),
1094            scopes: vec![],
1095            expires_at: Utc::now() - chrono::Duration::hours(1),
1096            created_at: Utc::now() - chrono::Duration::hours(2),
1097        };
1098        svc.auth_tokens.insert("expired-tok".into(), expired);
1099
1100        // The real token should be valid.
1101        assert!(svc.validate_auth_token(&token.token_id).is_ok());
1102        // The expired token should fail.
1103        let result = svc.validate_auth_token("expired-tok");
1104        assert!(result.is_err());
1105        assert!(result.unwrap_err().to_string().contains("expired"));
1106    }
1107
1108    #[test]
1109    fn revoke_auth_token_works() {
1110        let svc = AuthService::new_default();
1111        svc.register_hashed_credential("rev-agent", b"pass", vec![])
1112            .unwrap();
1113        let token = svc.authenticate("rev-agent", b"pass").unwrap();
1114        assert!(svc.revoke_auth_token(&token.token_id));
1115        assert!(svc.validate_auth_token(&token.token_id).is_err());
1116        // Second revoke returns false.
1117        assert!(!svc.revoke_auth_token(&token.token_id));
1118    }
1119
1120    #[test]
1121    fn check_scope_with_matching_scope() {
1122        let svc = AuthService::new_default();
1123        svc.register_hashed_credential("sc-agent", b"cred", vec!["admin".into(), "read".into()])
1124            .unwrap();
1125        let token = svc.authenticate("sc-agent", b"cred").unwrap();
1126        assert!(svc.check_scope(&token.token_id, "admin"));
1127        assert!(svc.check_scope(&token.token_id, "read"));
1128    }
1129
1130    #[test]
1131    fn check_scope_with_missing_scope_fails() {
1132        let svc = AuthService::new_default();
1133        svc.register_hashed_credential("sc-agent2", b"cred", vec!["read".into()])
1134            .unwrap();
1135        let token = svc.authenticate("sc-agent2", b"cred").unwrap();
1136        assert!(!svc.check_scope(&token.token_id, "write"));
1137        assert!(!svc.check_scope(&token.token_id, "admin"));
1138    }
1139
1140    #[test]
1141    fn raw_credentials_never_stored_in_hash() {
1142        let svc = AuthService::new_default();
1143        let raw = b"super_secret_password";
1144        svc.register_hashed_credential("hash-check", raw, vec![])
1145            .unwrap();
1146
1147        let cred = svc.hashed_credentials.get("hash-check").unwrap();
1148        // The hash must not equal the raw input.
1149        assert_ne!(cred.hash.as_slice(), raw.as_slice());
1150        // The hash should be 32 bytes (SHA-256).
1151        assert_eq!(cred.hash.len(), 32);
1152    }
1153
1154    #[test]
1155    fn check_scope_on_nonexistent_token_returns_false() {
1156        let svc = AuthService::new_default();
1157        assert!(!svc.check_scope("no-such-token", "read"));
1158    }
1159}