Skip to main content

wm_tools/expansion/
violet.rs

1//! Violet security surface — engagement tokens + model signing tools.
2//!
3//! PLAN_F F-1/F-2 router surface:
4//! - `violet.engagement.issue` / `violet.engagement.validate` / `violet.engagement.revoke`
5//! - `model.sign` / `model.verify`
6//!
7//! The issuer/signer Ed25519 keypairs live in memory for the life of the
8//! server process. Tokens and manifests carry their public keys, so
9//! verification by peers works without shared secrets; key persistence
10//! across restarts is a follow-up.
11
12#![forbid(unsafe_code)]
13
14use async_trait::async_trait;
15
16use serde_json::{Value, json};
17use std::path::Path;
18use std::sync::{Arc, Mutex, MutexGuard};
19use std::time::{SystemTime, UNIX_EPOCH};
20use wm_core::{Context, CoreError, EffectRow, Gana, Resource, Tool, ToolStats};
21use wm_governance::engagement_tokens::{
22    EngagementIssuer, EngagementScope, EngagementToken, TokenVerdict, sha256_hex,
23    verify_token_with_key,
24};
25use wm_governance::model_signing::{
26    ModelSignature, ModelSigner, sha256_hex_bytes, verify_model_hash,
27};
28use wm_governance::network_profile::AgentKeypair;
29
30type Issuer = Arc<Mutex<EngagementIssuer>>;
31type Signer = Arc<Mutex<ModelSigner>>;
32
33fn lock_issuer(issuer: &Issuer) -> wm_core::Result<MutexGuard<'_, EngagementIssuer>> {
34    issuer
35        .lock()
36        .map_err(|e| CoreError::Internal(format!("engagement issuer lock poisoned: {e}")))
37}
38
39fn lock_signer(signer: &Signer) -> wm_core::Result<MutexGuard<'_, ModelSigner>> {
40    signer
41        .lock()
42        .map_err(|e| CoreError::Internal(format!("model signer lock poisoned: {e}")))
43}
44
45fn random_seed() -> [u8; 32] {
46    let mut seed = [0u8; 32];
47    if getrandom::fill(&mut seed).is_err() {
48        // Entropy failure is not recoverable for key generation; this is a
49        // last-resort marker so persistence logic surfaces the problem.
50        tracing::error!("OS entropy unavailable for violet key generation");
51    }
52    seed
53}
54
55fn parse_hex32(hex: &str) -> Option<[u8; 32]> {
56    if hex.len() != 64 {
57        return None;
58    }
59    let mut out = [0u8; 32];
60    for (i, chunk) in hex.as_bytes().chunks_exact(2).enumerate() {
61        let hi = (chunk[0] as char).to_digit(16)? as u8;
62        let lo = (chunk[1] as char).to_digit(16)? as u8;
63        out[i] = (hi << 4) | lo;
64    }
65    Some(out)
66}
67
68/// Load a 32-byte seed from `dir/name` (hex), or generate + persist one
69/// with 0600 permissions. Key material never leaves the store directory.
70fn load_or_create_seed(dir: &Path, name: &str) -> std::io::Result<[u8; 32]> {
71    std::fs::create_dir_all(dir)?;
72    let path = dir.join(name);
73    if let Ok(contents) = std::fs::read_to_string(&path) {
74        if let Some(seed) = parse_hex32(contents.trim()) {
75            return Ok(seed);
76        }
77    }
78    let seed = random_seed();
79    let hex: String = seed.iter().fold(String::new(), |mut acc, b| {
80        use std::fmt::Write;
81        let _ = write!(acc, "{b:02x}");
82        acc
83    });
84    std::fs::write(&path, hex)?;
85    #[cfg(unix)]
86    {
87        use std::os::unix::fs::PermissionsExt;
88        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
89    }
90    Ok(seed)
91}
92
93fn persistent_issuer(dir: &Path) -> EngagementIssuer {
94    match load_or_create_seed(dir, "violet_issuer.key") {
95        Ok(seed) => EngagementIssuer::with_keypair(AgentKeypair::from_seed(seed)),
96        Err(e) => {
97            tracing::warn!("violet issuer key persistence failed ({e}); using in-memory key");
98            EngagementIssuer::new()
99        }
100    }
101}
102
103fn persistent_signer(dir: &Path) -> ModelSigner {
104    match load_or_create_seed(dir, "violet_signer.key") {
105        Ok(seed) => ModelSigner::with_keypair(AgentKeypair::from_seed(seed)),
106        Err(e) => {
107            tracing::warn!("violet signer key persistence failed ({e}); using in-memory key");
108            ModelSigner::new()
109        }
110    }
111}
112
113fn now_unix() -> i64 {
114    SystemTime::now()
115        .duration_since(UNIX_EPOCH)
116        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
117}
118
119fn parse_roe_hash(args: &Value) -> wm_core::Result<String> {
120    if let Some(text) = args.get("rules_of_engagement").and_then(Value::as_str) {
121        return Ok(sha256_hex(text));
122    }
123    if let Some(hash) = args.get("rules_of_engagement_hash").and_then(Value::as_str) {
124        return Ok(hash.trim().to_ascii_lowercase());
125    }
126    Err(CoreError::InvalidArgs(
127        "provide rules_of_engagement (text) or rules_of_engagement_hash (hex)".into(),
128    ))
129}
130
131fn parse_scope(args: &Value) -> EngagementScope {
132    match args.get("scope").and_then(Value::as_str) {
133        None | Some("poc") => EngagementScope::Poc,
134        Some("redteam") => EngagementScope::Redteam,
135        Some("demo") => EngagementScope::Demo,
136        Some(other) => EngagementScope::Custom(other.to_string()),
137    }
138}
139
140fn token_verdict_json(verdict: &TokenVerdict) -> Value {
141    match verdict {
142        TokenVerdict::Valid => json!({"verdict": "valid"}),
143        TokenVerdict::BadSignature => json!({"verdict": "bad_signature"}),
144        TokenVerdict::Revoked => json!({"verdict": "revoked"}),
145        TokenVerdict::Expired => json!({"verdict": "expired"}),
146        TokenVerdict::RulesOfEngagementMismatch { expected, actual } => json!({
147            "verdict": "roe_mismatch",
148            "expected": expected,
149            "actual": actual,
150        }),
151    }
152}
153
154// ── violet.engagement.issue ────────────────────────────────────────────
155
156/// `violet.engagement.issue` — issue a signed scope-of-engagement token.
157pub struct EngagementIssueTool {
158    issuer: Issuer,
159    stats: ToolStats,
160    effects: EffectRow,
161}
162
163impl EngagementIssueTool {
164    #[must_use]
165    pub fn new(issuer: Issuer) -> Self {
166        Self {
167            issuer,
168            stats: ToolStats::default(),
169            effects: EffectRow {
170                writes: vec![Resource::DharmaRules],
171                ..Default::default()
172            },
173        }
174    }
175}
176
177#[async_trait]
178impl Tool for EngagementIssueTool {
179    fn name(&self) -> &str {
180        "violet.engagement.issue"
181    }
182    fn gana(&self) -> Gana {
183        Gana::Room
184    }
185    fn effects(&self) -> &EffectRow {
186        &self.effects
187    }
188    fn description(&self) -> &str {
189        "Issue an Ed25519 scope-of-engagement token. Args: issued_to (str), scope (poc|redteam|demo|<custom>), rules_of_engagement (text) or rules_of_engagement_hash (hex), ttl_seconds (optional int; omitted = until revoked)."
190    }
191    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
192        let issued_to = args
193            .get("issued_to")
194            .and_then(Value::as_str)
195            .ok_or_else(|| CoreError::InvalidArgs("issued_to is required".into()))?;
196        let scope = parse_scope(&args);
197        let roe_hash = parse_roe_hash(&args)?;
198        let ttl = args.get("ttl_seconds").and_then(Value::as_i64);
199
200        let mut issuer = lock_issuer(&self.issuer)?;
201        let token = issuer.issue(issued_to, scope, &roe_hash, ttl);
202        let issuer_public_key = issuer.signer_public_key_hex();
203
204        Ok(json!({
205            "status": "success",
206            "issuer_public_key": issuer_public_key,
207            "rules_of_engagement_hash": roe_hash,
208            "token": token,
209        }))
210    }
211    fn stats(&self) -> &ToolStats {
212        &self.stats
213    }
214}
215
216// ── violet.engagement.validate ─────────────────────────────────────────
217
218/// `violet.engagement.validate` — stateless token verification.
219pub struct EngagementValidateTool {
220    stats: ToolStats,
221    effects: EffectRow,
222}
223
224impl EngagementValidateTool {
225    #[must_use]
226    pub fn new() -> Self {
227        Self {
228            stats: ToolStats::default(),
229            effects: EffectRow::read_only(vec![]),
230        }
231    }
232}
233
234impl Default for EngagementValidateTool {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240#[async_trait]
241impl Tool for EngagementValidateTool {
242    fn name(&self) -> &str {
243        "violet.engagement.validate"
244    }
245    fn gana(&self) -> Gana {
246        Gana::Room
247    }
248    fn effects(&self) -> &EffectRow {
249        &self.effects
250    }
251    fn description(&self) -> &str {
252        "Verify an engagement token against an explicit issuer public key. Args: token (object or JSON string), issuer_public_key (hex), rules_of_engagement (text) or rules_of_engagement_hash (hex), now (optional epoch seconds). Note: the issuer's out-of-band revocation set is not visible statelessly — only the token's revoked flag."
253    }
254    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
255        let token: EngagementToken = match args.get("token") {
256            Some(Value::String(s)) => serde_json::from_str(s)
257                .map_err(|e| CoreError::InvalidArgs(format!("token JSON: {e}")))?,
258            Some(v) => serde_json::from_value(v.clone())
259                .map_err(|e| CoreError::InvalidArgs(format!("token object: {e}")))?,
260            None => return Err(CoreError::InvalidArgs("token is required".into())),
261        };
262        let issuer_public_key = args
263            .get("issuer_public_key")
264            .and_then(Value::as_str)
265            .ok_or_else(|| CoreError::InvalidArgs("issuer_public_key is required".into()))?;
266        let roe_hash = parse_roe_hash(&args)?;
267        let now = args
268            .get("now")
269            .and_then(Value::as_i64)
270            .unwrap_or_else(now_unix);
271
272        let outcome = verify_token_with_key(&token, &roe_hash, issuer_public_key, now);
273        match outcome {
274            Ok(verdict) => {
275                let mut out = token_verdict_json(&verdict);
276                out["status"] = json!("success");
277                out["token_id"] = json!(token.id);
278                Ok(out)
279            }
280            Err(e) => Ok(json!({
281                "status": "success",
282                "verdict": "malformed_signature",
283                "token_id": token.id,
284                "reason": e.to_string(),
285            })),
286        }
287    }
288    fn stats(&self) -> &ToolStats {
289        &self.stats
290    }
291}
292
293// ── violet.engagement.revoke ───────────────────────────────────────────
294
295/// `violet.engagement.revoke` — revoke a token issued by this server.
296pub struct EngagementRevokeTool {
297    issuer: Issuer,
298    stats: ToolStats,
299    effects: EffectRow,
300}
301
302impl EngagementRevokeTool {
303    #[must_use]
304    pub fn new(issuer: Issuer) -> Self {
305        Self {
306            issuer,
307            stats: ToolStats::default(),
308            effects: EffectRow {
309                writes: vec![Resource::DharmaRules],
310                ..Default::default()
311            },
312        }
313    }
314}
315
316#[async_trait]
317impl Tool for EngagementRevokeTool {
318    fn name(&self) -> &str {
319        "violet.engagement.revoke"
320    }
321    fn gana(&self) -> Gana {
322        Gana::Room
323    }
324    fn effects(&self) -> &EffectRow {
325        &self.effects
326    }
327    fn description(&self) -> &str {
328        "Revoke an engagement token issued by this server. Args: id (evt_...)."
329    }
330    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
331        let id = args
332            .get("id")
333            .and_then(Value::as_str)
334            .ok_or_else(|| CoreError::InvalidArgs("id is required".into()))?;
335        let mut issuer = lock_issuer(&self.issuer)?;
336        issuer
337            .revoke(id)
338            .map_err(|e| CoreError::InvalidArgs(e.to_string()))?;
339        Ok(json!({"status": "success", "revoked": id}))
340    }
341    fn stats(&self) -> &ToolStats {
342        &self.stats
343    }
344}
345
346// ── model.sign ─────────────────────────────────────────────────────────
347
348/// `model.sign` — Ed25519-sign an artifact hash (or content).
349pub struct ModelSignTool {
350    signer: Signer,
351    stats: ToolStats,
352    effects: EffectRow,
353}
354
355impl ModelSignTool {
356    #[must_use]
357    pub fn new(signer: Signer) -> Self {
358        Self {
359            signer,
360            stats: ToolStats::default(),
361            effects: EffectRow::read_only(vec![]),
362        }
363    }
364}
365
366#[async_trait]
367impl Tool for ModelSignTool {
368    fn name(&self) -> &str {
369        "model.sign"
370    }
371    fn gana(&self) -> Gana {
372        Gana::ExtendedNet
373    }
374    fn effects(&self) -> &EffectRow {
375        &self.effects
376    }
377    fn description(&self) -> &str {
378        "Ed25519-sign an artifact manifest. Args: artifact_id (str), content (str; hashed) or content_hash (sha256 hex), nonce (optional), rules_of_engagement_hash (optional; binding is re-signed)."
379    }
380    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
381        let artifact_id = args
382            .get("artifact_id")
383            .and_then(Value::as_str)
384            .ok_or_else(|| CoreError::InvalidArgs("artifact_id is required".into()))?;
385        let content_hash = match (
386            args.get("content").and_then(Value::as_str),
387            args.get("content_hash").and_then(Value::as_str),
388        ) {
389            (Some(content), _) => sha256_hex_bytes(content.as_bytes()),
390            (None, Some(hash)) => hash.trim().to_ascii_lowercase(),
391            (None, None) => {
392                return Err(CoreError::InvalidArgs(
393                    "provide content (str) or content_hash (sha256 hex)".into(),
394                ));
395            }
396        };
397        let nonce = args.get("nonce").and_then(Value::as_str);
398        let roe_hash = args
399            .get("rules_of_engagement_hash")
400            .and_then(Value::as_str)
401            .map(|h| h.trim().to_ascii_lowercase());
402
403        let signer = lock_signer(&self.signer)?;
404        let manifest = signer.sign_hash(artifact_id, &content_hash);
405        let manifest = match (&nonce, &roe_hash) {
406            (Some(nonce), Some(roe)) => signer.bind_engagement(manifest, nonce, roe),
407            (Some(nonce), None) => signer.bind_engagement(manifest, nonce, ""),
408            (None, Some(roe)) => signer.bind_engagement(manifest, "", roe),
409            (None, None) => manifest,
410        };
411        let signer_public_key = signer.signer_public_key_hex();
412
413        Ok(json!({
414            "status": "success",
415            "signer_public_key": signer_public_key,
416            "signature": manifest,
417        }))
418    }
419    fn stats(&self) -> &ToolStats {
420        &self.stats
421    }
422}
423
424// ── model.verify ───────────────────────────────────────────────────────
425
426/// `model.verify` — verify a signed artifact manifest.
427pub struct ModelVerifyTool {
428    stats: ToolStats,
429    effects: EffectRow,
430}
431
432impl ModelVerifyTool {
433    #[must_use]
434    pub fn new() -> Self {
435        Self {
436            stats: ToolStats::default(),
437            effects: EffectRow::read_only(vec![]),
438        }
439    }
440}
441
442impl Default for ModelVerifyTool {
443    fn default() -> Self {
444        Self::new()
445    }
446}
447
448#[async_trait]
449impl Tool for ModelVerifyTool {
450    fn name(&self) -> &str {
451        "model.verify"
452    }
453    fn gana(&self) -> Gana {
454        Gana::ExtendedNet
455    }
456    fn effects(&self) -> &EffectRow {
457        &self.effects
458    }
459    fn description(&self) -> &str {
460        "Verify a signed model/artifact manifest. Args: signature (manifest object), content (str; hashed) or content_hash (sha256 hex)."
461    }
462    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
463        let manifest: ModelSignature = match args.get("signature") {
464            Some(Value::String(s)) => serde_json::from_str(s)
465                .map_err(|e| CoreError::InvalidArgs(format!("signature JSON: {e}")))?,
466            Some(v) => serde_json::from_value(v.clone())
467                .map_err(|e| CoreError::InvalidArgs(format!("signature object: {e}")))?,
468            None => return Err(CoreError::InvalidArgs("signature is required".into())),
469        };
470        let content_hash = match (
471            args.get("content").and_then(Value::as_str),
472            args.get("content_hash").and_then(Value::as_str),
473        ) {
474            (Some(content), _) => sha256_hex_bytes(content.as_bytes()),
475            (None, Some(hash)) => hash.trim().to_ascii_lowercase(),
476            (None, None) => {
477                return Err(CoreError::InvalidArgs(
478                    "provide content (str) or content_hash (sha256 hex)".into(),
479                ));
480            }
481        };
482
483        let verdict = verify_model_hash(&content_hash, &manifest);
484        let out = match verdict {
485            wm_governance::model_signing::ModelVerdict::Valid => {
486                json!({"status": "success", "verdict": "valid"})
487            }
488            wm_governance::model_signing::ModelVerdict::BadSignature => {
489                json!({"status": "success", "verdict": "bad_signature"})
490            }
491            wm_governance::model_signing::ModelVerdict::HashMismatch { expected, actual } => {
492                json!({
493                    "status": "success",
494                    "verdict": "hash_mismatch",
495                    "expected": expected,
496                    "actual": actual,
497                })
498            }
499        };
500        Ok(out)
501    }
502    fn stats(&self) -> &ToolStats {
503        &self.stats
504    }
505}
506
507/// Register the Violet security tool surface (5 tools) with fresh
508/// in-memory keypairs (tests / ephemeral servers).
509#[must_use]
510pub fn register_violet(registry: &wm_dispatch::ToolRegistry) -> wm_dispatch::ToolRegistry {
511    register_violet_with_keys(registry, None)
512}
513
514/// Register the Violet security tool surface with persistent keys under
515/// `key_dir` (0600 seed files; issuer/signer identities survive restarts).
516#[must_use]
517pub fn register_violet_persistent(
518    registry: &wm_dispatch::ToolRegistry,
519    key_dir: &Path,
520) -> wm_dispatch::ToolRegistry {
521    register_violet_with_keys(registry, Some(key_dir))
522}
523
524fn register_violet_with_keys(
525    registry: &wm_dispatch::ToolRegistry,
526    key_dir: Option<&Path>,
527) -> wm_dispatch::ToolRegistry {
528    let (issuer, signer) = match key_dir {
529        Some(dir) => (persistent_issuer(dir), persistent_signer(dir)),
530        None => (EngagementIssuer::new(), ModelSigner::new()),
531    };
532    let issuer: Issuer = Arc::new(Mutex::new(issuer));
533    let signer: Signer = Arc::new(Mutex::new(signer));
534    registry
535        .register(Arc::new(EngagementIssueTool::new(issuer.clone())))
536        .register(Arc::new(EngagementValidateTool::new()))
537        .register(Arc::new(EngagementRevokeTool::new(issuer)))
538        .register(Arc::new(ModelSignTool::new(signer)))
539        .register(Arc::new(ModelVerifyTool::new()))
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn seed_persistence_roundtrip_is_stable() {
548        let dir = tempfile::tempdir().unwrap();
549        let s1 = load_or_create_seed(dir.path(), "violet_issuer.key").unwrap();
550        let s2 = load_or_create_seed(dir.path(), "violet_issuer.key").unwrap();
551        assert_eq!(s1, s2);
552        let i1 = EngagementIssuer::with_keypair(AgentKeypair::from_seed(s1));
553        let i2 = EngagementIssuer::with_keypair(AgentKeypair::from_seed(s2));
554        assert_eq!(i1.signer_public_key_hex(), i2.signer_public_key_hex());
555    }
556
557    #[test]
558    fn parse_hex32_validation() {
559        assert!(parse_hex32("zz").is_none());
560        assert!(parse_hex32(&"0".repeat(63)).is_none());
561        assert!(parse_hex32(&"a".repeat(64)).is_some());
562    }
563
564    #[cfg(unix)]
565    #[test]
566    fn seed_file_is_owner_only() {
567        use std::os::unix::fs::PermissionsExt;
568        let dir = tempfile::tempdir().unwrap();
569        load_or_create_seed(dir.path(), "key").unwrap();
570        let mode = std::fs::metadata(dir.path().join("key"))
571            .unwrap()
572            .permissions()
573            .mode();
574        assert_eq!(mode & 0o777, 0o600);
575    }
576}