1#![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 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
68fn 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
154pub 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
216pub 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
293pub 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
346pub 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
424pub 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#[must_use]
510pub fn register_violet(registry: &wm_dispatch::ToolRegistry) -> wm_dispatch::ToolRegistry {
511 register_violet_with_keys(registry, None)
512}
513
514#[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}