Skip to main content

wm_core/
attestation.rs

1//! Tool Capability Attestation — Signed manifests for tool provenance and trust.
2//!
3//! Implements supply chain security for the tool ecosystem:
4//! - **Signed manifests**: Each tool has a cryptographic manifest declaring its
5//!   capabilities, effects, and provenance, signed with an HMAC key.
6//! - **Provenance verification**: Verify that a tool's manifest hasn't been
7//!   tampered with and comes from a trusted publisher.
8//! - **Trust scope controls**: Restrict which tools external MCP servers can
9//!   invoke based on declared capabilities and trust level.
10
11use crate::effects::EffectRow;
12use hmac::{Hmac, Mac};
13use sha2::{Digest, Sha256};
14
15type HmacSha256 = Hmac<Sha256>;
16
17/// Compute an HMAC-SHA256 signature (hex-encoded) over a payload with a key.
18///
19/// Returns `None` when the key is invalid (e.g. empty) — callers should
20/// treat that as "signing unavailable" rather than produce an unsigned
21/// artifact silently.
22#[must_use]
23pub fn sign_hmac(payload: &str, key: &[u8]) -> Option<String> {
24    let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
25        return None;
26    };
27    mac.update(payload.as_bytes());
28    Some(format!("{:x}", mac.finalize().into_bytes()))
29}
30
31/// Verify an HMAC-SHA256 signature (hex-encoded) over a payload with a key.
32///
33/// An empty signature is never valid.
34#[must_use]
35pub fn verify_hmac(payload: &str, signature: &str, key: &[u8]) -> bool {
36    if signature.is_empty() {
37        return false;
38    }
39    sign_hmac(payload, key).is_some_and(|expected| expected == signature)
40}
41
42/// A tool capability manifest — declares what a tool can do and who published it.
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct ToolManifest {
45    /// Tool name (e.g., "memory.search").
46    pub tool_name: String,
47    /// Tool version (semver).
48    pub version: String,
49    /// Publisher identity (e.g., "whitemagic-core", "external:acme").
50    pub publisher: String,
51    /// Human-readable description (sanitized).
52    pub description: String,
53    /// Declared effects (reads, writes, spawns).
54    #[serde(default)]
55    pub effects: EffectSummary,
56    /// Declared capabilities (e.g., "read_only", "network_access", "filesystem_write").
57    pub capabilities: Vec<String>,
58    /// Trust level assigned to this tool (0.0 = untrusted, 1.0 = fully trusted).
59    pub trust_level: f32,
60    /// Whether this tool requires human review before execution.
61    pub requires_human_review: bool,
62    /// Manifest creation timestamp (Unix seconds).
63    pub created_at: i64,
64    /// HMAC-SHA256 signature over the manifest content.
65    #[serde(default)]
66    pub signature: String,
67}
68
69/// A compact summary of effects for serialization in manifests.
70#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
71pub struct EffectSummary {
72    /// Resources the tool reads.
73    #[serde(default)]
74    pub reads: Vec<String>,
75    /// Resources the tool writes.
76    #[serde(default)]
77    pub writes: Vec<String>,
78    /// Whether the tool can spawn subprocesses.
79    #[serde(default)]
80    pub spawns: bool,
81}
82
83impl EffectSummary {
84    /// Create from an `EffectRow`.
85    #[must_use]
86    pub fn from_effect_row(effects: &EffectRow) -> Self {
87        Self {
88            reads: effects.reads.iter().map(|r| format!("{r:?}")).collect(),
89            writes: effects.writes.iter().map(|r| format!("{r:?}")).collect(),
90            spawns: effects.spawns,
91        }
92    }
93
94    /// Whether this manifest declares any destructive (write) effects.
95    #[must_use]
96    pub fn has_destructive_effects(&self) -> bool {
97        !self.writes.is_empty() || self.spawns
98    }
99}
100
101impl ToolManifest {
102    /// Create a new unsigned manifest.
103    #[must_use]
104    pub fn new(tool_name: &str, version: &str, publisher: &str, description: &str) -> Self {
105        Self {
106            tool_name: tool_name.to_string(),
107            version: version.to_string(),
108            publisher: publisher.to_string(),
109            description: description.to_string(),
110            effects: EffectSummary::default(),
111            capabilities: Vec::new(),
112            trust_level: 0.5,
113            requires_human_review: false,
114            created_at: chrono::Utc::now().timestamp(),
115            signature: String::new(),
116        }
117    }
118
119    /// Set effects on the manifest.
120    #[must_use]
121    pub fn with_effects(mut self, effects: EffectSummary) -> Self {
122        self.effects = effects;
123        self
124    }
125
126    /// Set capabilities on the manifest.
127    #[must_use]
128    pub fn with_capabilities(mut self, caps: Vec<String>) -> Self {
129        self.capabilities = caps;
130        self
131    }
132
133    /// Set trust level on the manifest.
134    #[must_use]
135    pub const fn with_trust(mut self, trust: f32) -> Self {
136        self.trust_level = trust.clamp(0.0, 1.0);
137        self
138    }
139
140    /// Require human review.
141    #[must_use]
142    pub const fn require_review(mut self) -> Self {
143        self.requires_human_review = true;
144        self
145    }
146
147    /// Compute the payload to sign (all fields except signature).
148    #[must_use]
149    pub fn signing_payload(&self) -> String {
150        // Serialize without the signature field
151        let without_sig = Self {
152            signature: String::new(),
153            ..self.clone()
154        };
155        serde_json::to_string(&without_sig).unwrap_or_default()
156    }
157
158    /// Sign the manifest with an HMAC key.
159    ///
160    /// Returns a new manifest with the signature set.
161    #[must_use]
162    pub fn sign(mut self, key: &[u8]) -> Self {
163        let payload = self.signing_payload();
164        if let Some(sig) = sign_hmac(&payload, key) {
165            self.signature = sig;
166        }
167        self
168    }
169
170    /// Verify the manifest's signature.
171    ///
172    /// Returns true if the signature matches the current content.
173    #[must_use]
174    pub fn verify_signature(&self, key: &[u8]) -> bool {
175        verify_hmac(&self.signing_payload(), &self.signature, key)
176    }
177
178    /// Whether this manifest declares a specific capability.
179    #[must_use]
180    pub fn has_capability(&self, cap: &str) -> bool {
181        self.capabilities.iter().any(|c| c == cap)
182    }
183
184    /// Whether this manifest has destructive effects.
185    #[must_use]
186    pub fn is_destructive(&self) -> bool {
187        self.effects.has_destructive_effects()
188    }
189}
190
191/// Trust scope — defines what external MCP servers are allowed to do.
192#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
193pub struct TrustScope {
194    /// Name of this trust scope (e.g., "default", "strict", "external").
195    pub name: String,
196    /// Minimum trust level required for tool execution.
197    pub min_trust: f32,
198    /// Allowed tool name patterns (empty = all allowed).
199    #[serde(default)]
200    pub allowed_tools: Vec<String>,
201    /// Denied tool name patterns (takes precedence over allowed).
202    #[serde(default)]
203    pub denied_tools: Vec<String>,
204    /// Whether destructive tools are allowed.
205    pub allow_destructive: bool,
206    /// Whether network access tools are allowed.
207    pub allow_network: bool,
208    /// Whether filesystem write tools are allowed.
209    pub allow_filesystem_write: bool,
210    /// Whether human review is required for all tools in this scope.
211    pub require_review: bool,
212    /// Maximum number of tool calls per minute (0 = unlimited).
213    pub max_calls_per_minute: u32,
214}
215
216impl Default for TrustScope {
217    fn default() -> Self {
218        Self {
219            name: "default".into(),
220            min_trust: 0.5,
221            allowed_tools: Vec::new(),
222            denied_tools: Vec::new(),
223            allow_destructive: false,
224            allow_network: false,
225            allow_filesystem_write: false,
226            require_review: false,
227            max_calls_per_minute: 60,
228        }
229    }
230}
231
232impl TrustScope {
233    /// Strict scope for untrusted external servers.
234    #[must_use]
235    pub fn strict() -> Self {
236        Self {
237            name: "strict".into(),
238            min_trust: 0.8,
239            allowed_tools: vec!["memory.search".into(), "memory.recall".into()],
240            denied_tools: vec!["file.".into(), "process.".into(), "network.".into()],
241            allow_destructive: false,
242            allow_network: false,
243            allow_filesystem_write: false,
244            require_review: true,
245            max_calls_per_minute: 10,
246        }
247    }
248
249    /// Permissive scope for trusted internal servers.
250    #[must_use]
251    pub fn permissive() -> Self {
252        Self {
253            name: "permissive".into(),
254            min_trust: 0.3,
255            allow_destructive: true,
256            allow_network: true,
257            allow_filesystem_write: true,
258            require_review: false,
259            max_calls_per_minute: 200,
260            ..Self::default()
261        }
262    }
263
264    /// Check if a tool is allowed under this trust scope.
265    #[must_use]
266    pub fn is_tool_allowed(&self, manifest: &ToolManifest) -> bool {
267        // Check trust level
268        if manifest.trust_level < self.min_trust {
269            return false;
270        }
271
272        // Check denied list
273        if self
274            .denied_tools
275            .iter()
276            .any(|p| manifest.tool_name.starts_with(p))
277        {
278            return false;
279        }
280
281        // Check allowed list (empty = all allowed)
282        if !self.allowed_tools.is_empty()
283            && !self
284                .allowed_tools
285                .iter()
286                .any(|p| manifest.tool_name.starts_with(p))
287        {
288            return false;
289        }
290
291        // Check destructive
292        if manifest.is_destructive() && !self.allow_destructive {
293            return false;
294        }
295
296        // Check network access
297        if manifest.has_capability("network_access") && !self.allow_network {
298            return false;
299        }
300
301        // Check filesystem write
302        if manifest.has_capability("filesystem_write") && !self.allow_filesystem_write {
303            return false;
304        }
305
306        // Check human review requirement
307        if self.require_review && !manifest.requires_human_review {
308            // Tool doesn't declare it needs review, but scope requires it
309            // This is a warning, not a hard block — the caller should enforce review
310        }
311
312        true
313    }
314}
315
316/// Registry of known tool manifests with verification.
317pub struct ToolAttestationRegistry {
318    /// Known manifests keyed by tool name.
319    manifests: ahash::AHashMap<String, ToolManifest>,
320    /// Signing key for manifest verification.
321    signing_key: Vec<u8>,
322    /// Trust scope for external tools.
323    external_scope: TrustScope,
324    /// Set of trusted publishers.
325    trusted_publishers: Vec<String>,
326}
327
328impl ToolAttestationRegistry {
329    /// Create a new registry with the given signing key.
330    #[must_use]
331    pub fn new(signing_key: Vec<u8>) -> Self {
332        Self {
333            manifests: ahash::AHashMap::new(),
334            signing_key,
335            external_scope: TrustScope::default(),
336            trusted_publishers: vec!["whitemagic-core".into()],
337        }
338    }
339
340    /// Set the trust scope for external tools.
341    #[must_use]
342    pub fn with_external_scope(mut self, scope: TrustScope) -> Self {
343        self.external_scope = scope;
344        self
345    }
346
347    /// Add a trusted publisher.
348    pub fn trust_publisher(&mut self, publisher: &str) {
349        if !self.trusted_publishers.contains(&publisher.to_string()) {
350            self.trusted_publishers.push(publisher.to_string());
351        }
352    }
353
354    /// Register a tool manifest.
355    ///
356    /// Verifies the manifest's signature before registering. Returns false
357    /// if the signature is invalid.
358    pub fn register(&mut self, manifest: ToolManifest) -> bool {
359        // Verify signature
360        if !manifest.verify_signature(&self.signing_key) {
361            return false;
362        }
363
364        // Check publisher is trusted
365        if !self.trusted_publishers.contains(&manifest.publisher) {
366            return false;
367        }
368
369        self.manifests.insert(manifest.tool_name.clone(), manifest);
370        true
371    }
372
373    /// Register a tool manifest without signature verification (for self-signed tools).
374    ///
375    /// The manifest must still come from a trusted publisher.
376    pub fn register_unsigned(&mut self, manifest: ToolManifest) -> bool {
377        if !self.trusted_publishers.contains(&manifest.publisher) {
378            return false;
379        }
380        self.manifests.insert(manifest.tool_name.clone(), manifest);
381        true
382    }
383
384    /// Get a tool's manifest.
385    #[must_use]
386    pub fn get(&self, tool_name: &str) -> Option<&ToolManifest> {
387        self.manifests.get(tool_name)
388    }
389
390    /// Check if a tool is allowed under the current trust scope.
391    #[must_use]
392    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
393        let Some(manifest) = self.manifests.get(tool_name) else {
394            return false; // Unknown tools are not allowed
395        };
396
397        // Internal tools (from whitemagic-core) bypass external scope
398        if manifest.publisher == "whitemagic-core" {
399            return true;
400        }
401
402        // External tools must pass the trust scope
403        self.external_scope.is_tool_allowed(manifest)
404    }
405
406    /// Verify a manifest's provenance (signature + publisher).
407    #[must_use]
408    pub fn verify_provenance(&self, manifest: &ToolManifest) -> bool {
409        manifest.verify_signature(&self.signing_key)
410            && self.trusted_publishers.contains(&manifest.publisher)
411    }
412
413    /// List all registered tool names.
414    #[must_use]
415    pub fn registered_tools(&self) -> Vec<String> {
416        self.manifests.keys().cloned().collect()
417    }
418
419    /// Number of registered tools.
420    #[must_use]
421    pub fn len(&self) -> usize {
422        self.manifests.len()
423    }
424
425    /// Whether the registry is empty.
426    #[must_use]
427    pub fn is_empty(&self) -> bool {
428        self.manifests.is_empty()
429    }
430}
431
432/// Compute SHA-256 hash of a manifest (for fingerprinting).
433#[must_use]
434pub fn manifest_hash(manifest: &ToolManifest) -> String {
435    let payload = manifest.signing_payload();
436    let mut hasher = Sha256::new();
437    hasher.update(payload.as_bytes());
438    format!("{:x}", hasher.finalize())
439}
440
441// ── Tests ─────────────────────────────────────────────────────────────
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    const TEST_KEY: &[u8] = b"test_signing_key_123";
448
449    fn make_manifest(name: &str, publisher: &str) -> ToolManifest {
450        ToolManifest::new(name, "1.0.0", publisher, "A test tool")
451            .with_trust(0.8)
452            .with_capabilities(vec!["read_only".into()])
453    }
454
455    #[test]
456    fn manifest_sign_and_verify() {
457        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
458        assert!(
459            manifest.verify_signature(TEST_KEY),
460            "Signed manifest should verify"
461        );
462    }
463
464    #[test]
465    fn manifest_tamper_detected() {
466        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
467        let tampered = ToolManifest {
468            description: "Tampered description".into(),
469            ..manifest
470        };
471        assert!(
472            !tampered.verify_signature(TEST_KEY),
473            "Tampered manifest should fail verification"
474        );
475    }
476
477    #[test]
478    fn manifest_unsigned_fails_verification() {
479        let manifest = make_manifest("memory.search", "whitemagic-core");
480        assert!(!manifest.verify_signature(TEST_KEY));
481    }
482
483    #[test]
484    fn manifest_wrong_key_fails() {
485        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
486        assert!(!manifest.verify_signature(b"wrong_key"));
487    }
488
489    #[test]
490    fn manifest_has_capability() {
491        let manifest = make_manifest("memory.search", "whitemagic-core")
492            .with_capabilities(vec!["read_only".into(), "search".into()]);
493        assert!(manifest.has_capability("read_only"));
494        assert!(manifest.has_capability("search"));
495        assert!(!manifest.has_capability("network_access"));
496    }
497
498    #[test]
499    fn manifest_destructive_detection() {
500        let manifest = ToolManifest::new("file.write", "1.0.0", "whitemagic-core", "Write file")
501            .with_effects(EffectSummary {
502                writes: vec!["Filesystem".into()],
503                ..Default::default()
504            });
505        assert!(manifest.is_destructive());
506
507        let read_only = ToolManifest::new("memory.search", "1.0.0", "whitemagic-core", "Search")
508            .with_effects(EffectSummary {
509                reads: vec!["Galaxy".into()],
510                ..Default::default()
511            });
512        assert!(!read_only.is_destructive());
513    }
514
515    #[test]
516    fn trust_scope_default_allows_trusted() {
517        let scope = TrustScope::default();
518        let manifest = make_manifest("memory.search", "whitemagic-core").with_trust(0.6);
519        assert!(scope.is_tool_allowed(&manifest));
520    }
521
522    #[test]
523    fn trust_scope_blocks_low_trust() {
524        let scope = TrustScope::default();
525        let manifest = make_manifest("memory.search", "external").with_trust(0.2);
526        assert!(!scope.is_tool_allowed(&manifest));
527    }
528
529    #[test]
530    fn trust_scope_strict_blocks_destructive() {
531        let scope = TrustScope::strict();
532        let manifest = ToolManifest::new("file.write", "1.0.0", "external", "Write file")
533            .with_trust(0.9)
534            .with_effects(EffectSummary {
535                writes: vec!["Filesystem".into()],
536                ..Default::default()
537            });
538        assert!(!scope.is_tool_allowed(&manifest));
539    }
540
541    #[test]
542    fn trust_scope_strict_blocks_network() {
543        let scope = TrustScope::strict();
544        let manifest = ToolManifest::new("http.fetch", "1.0.0", "external", "Fetch URL")
545            .with_trust(0.9)
546            .with_capabilities(vec!["network_access".into()]);
547        assert!(!scope.is_tool_allowed(&manifest));
548    }
549
550    #[test]
551    fn trust_scope_denied_list_takes_precedence() {
552        let scope = TrustScope {
553            allowed_tools: vec!["memory.".into()],
554            denied_tools: vec!["memory.delete".into()],
555            ..TrustScope::permissive()
556        };
557        let manifest = make_manifest("memory.delete", "external").with_trust(0.9);
558        assert!(!scope.is_tool_allowed(&manifest));
559    }
560
561    #[test]
562    fn registry_register_signed_manifest() {
563        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
564        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
565        assert!(registry.register(manifest));
566        assert_eq!(registry.len(), 1);
567    }
568
569    #[test]
570    fn registry_rejects_invalid_signature() {
571        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
572        let manifest = make_manifest("memory.search", "whitemagic-core").sign(b"wrong_key");
573        assert!(!registry.register(manifest));
574        assert_eq!(registry.len(), 0);
575    }
576
577    #[test]
578    fn registry_rejects_untrusted_publisher() {
579        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
580        let manifest = make_manifest("memory.search", "untrusted").sign(TEST_KEY);
581        assert!(!registry.register(manifest));
582    }
583
584    #[test]
585    fn registry_allows_trusted_publisher() {
586        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
587        registry.trust_publisher("external:acme");
588        let manifest = make_manifest("custom.tool", "external:acme").sign(TEST_KEY);
589        assert!(registry.register(manifest));
590    }
591
592    #[test]
593    fn registry_internal_tools_bypass_scope() {
594        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec())
595            .with_external_scope(TrustScope::strict());
596        let manifest = ToolManifest::new("file.write", "1.0.0", "whitemagic-core", "Write")
597            .with_trust(0.5)
598            .with_effects(EffectSummary {
599                writes: vec!["Filesystem".into()],
600                ..Default::default()
601            })
602            .sign(TEST_KEY);
603        registry.register(manifest);
604
605        // Internal tool should be allowed even under strict scope
606        assert!(registry.is_tool_allowed("file.write"));
607    }
608
609    #[test]
610    fn registry_external_tools_checked_against_scope() {
611        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec())
612            .with_external_scope(TrustScope::strict());
613        registry.trust_publisher("external:acme");
614        let manifest = ToolManifest::new("file.write", "1.0.0", "external:acme", "Write")
615            .with_trust(0.9)
616            .with_effects(EffectSummary {
617                writes: vec!["Filesystem".into()],
618                ..Default::default()
619            })
620            .sign(TEST_KEY);
621        registry.register(manifest);
622
623        // External destructive tool should be blocked by strict scope
624        assert!(!registry.is_tool_allowed("file.write"));
625    }
626
627    #[test]
628    fn registry_unknown_tool_not_allowed() {
629        let registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
630        assert!(!registry.is_tool_allowed("unknown.tool"));
631    }
632
633    #[test]
634    fn registry_verify_provenance() {
635        let registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
636        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
637        assert!(registry.verify_provenance(&manifest));
638
639        let untrusted = make_manifest("memory.search", "untrusted").sign(TEST_KEY);
640        assert!(!registry.verify_provenance(&untrusted));
641    }
642
643    #[test]
644    fn manifest_hash_deterministic() {
645        let m1 = make_manifest("memory.search", "whitemagic-core");
646        let m2 = make_manifest("memory.search", "whitemagic-core");
647        assert_eq!(manifest_hash(&m1), manifest_hash(&m2));
648    }
649
650    #[test]
651    fn manifest_hash_changes_with_content() {
652        let m1 = make_manifest("memory.search", "whitemagic-core");
653        let m2 = make_manifest("memory.search", "whitemagic-core").with_trust(0.9);
654        assert_ne!(manifest_hash(&m1), manifest_hash(&m2));
655    }
656
657    #[test]
658    fn registry_registered_tools_list() {
659        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
660        registry.register(make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY));
661        registry.register(make_manifest("memory.recall", "whitemagic-core").sign(TEST_KEY));
662
663        let tools = registry.registered_tools();
664        assert_eq!(tools.len(), 2);
665        assert!(tools.contains(&"memory.search".to_string()));
666        assert!(tools.contains(&"memory.recall".to_string()));
667    }
668
669    #[test]
670    fn trust_scope_permissive_allows_most() {
671        let scope = TrustScope::permissive();
672        let manifest = ToolManifest::new("file.write", "1.0.0", "external", "Write")
673            .with_trust(0.5)
674            .with_effects(EffectSummary {
675                writes: vec!["Filesystem".into()],
676                ..Default::default()
677            })
678            .with_capabilities(vec!["filesystem_write".into()]);
679        assert!(scope.is_tool_allowed(&manifest));
680    }
681}