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. Ed25519 (`ed25519:<hex>`) is the
6//!   preferred scheme; bare-hex HMAC-SHA256 remains as the legacy migration path.
7//! - **Provenance verification**: Verify that a tool's manifest hasn't been
8//!   tampered with and comes from a trusted publisher.
9//! - **Trust scope controls**: Restrict which tools external MCP servers can
10//!   invoke based on declared capabilities and trust level.
11
12use crate::effects::EffectRow;
13use hmac::{Hmac, Mac};
14use sha2::{Digest, Sha256};
15
16type HmacSha256 = Hmac<Sha256>;
17
18/// Prefix marking an Ed25519 signature (PLAN_F F-2). Bare hex remains HMAC.
19pub const ED25519_SIG_PREFIX: &str = "ed25519:";
20
21/// Sign a payload with an Ed25519 signing key, returning `ed25519:<hex>`.
22///
23/// Asymmetric counterpart to [`sign_hmac`]: validators only need the
24/// issuer's public key, so a compromised registry key cannot forge.
25#[must_use]
26pub fn sign_ed25519(payload: &str, signing_key: &ed25519_dalek::SigningKey) -> String {
27    use ed25519_dalek::Signer;
28    let sig = signing_key.sign(payload.as_bytes());
29    format!("{ED25519_SIG_PREFIX}{}", encode_hex(&sig.to_bytes()))
30}
31
32/// Verify an `ed25519:<hex>` signature over a payload with a public key.
33#[must_use]
34pub fn verify_ed25519(
35    payload: &str,
36    signature: &str,
37    verifying_key: &ed25519_dalek::VerifyingKey,
38) -> bool {
39    use ed25519_dalek::Verifier;
40    let Some(hex) = signature.strip_prefix(ED25519_SIG_PREFIX) else {
41        return false;
42    };
43    let Some(bytes) = decode_hex(hex) else {
44        return false;
45    };
46    let Ok(bytes): Result<[u8; 64], _> = bytes.try_into() else {
47        return false;
48    };
49    let sig = ed25519_dalek::Signature::from_bytes(&bytes);
50    verifying_key.verify(payload.as_bytes(), &sig).is_ok()
51}
52
53fn encode_hex(bytes: &[u8]) -> String {
54    use std::fmt::Write;
55    bytes.iter().fold(String::new(), |mut acc, b| {
56        let _ = write!(acc, "{b:02x}");
57        acc
58    })
59}
60
61fn decode_hex(hex: &str) -> Option<Vec<u8>> {
62    if hex.len() % 2 != 0 {
63        return None;
64    }
65    let bytes = hex.as_bytes();
66    let mut out = Vec::with_capacity(hex.len() / 2);
67    for chunk in bytes.chunks_exact(2) {
68        let hi = hex_val(chunk[0])?;
69        let lo = hex_val(chunk[1])?;
70        out.push((hi << 4) | lo);
71    }
72    Some(out)
73}
74
75const fn hex_val(b: u8) -> Option<u8> {
76    match b {
77        b'0'..=b'9' => Some(b - b'0'),
78        b'a'..=b'f' => Some(b - b'a' + 10),
79        b'A'..=b'F' => Some(b - b'A' + 10),
80        _ => None,
81    }
82}
83
84/// Compute an HMAC-SHA256 signature (hex-encoded) over a payload with a key.
85///
86/// Returns `None` when the key is invalid (e.g. empty) — callers should
87/// treat that as "signing unavailable" rather than produce an unsigned
88/// artifact silently.
89#[must_use]
90pub fn sign_hmac(payload: &str, key: &[u8]) -> Option<String> {
91    let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
92        return None;
93    };
94    mac.update(payload.as_bytes());
95    Some(format!("{:x}", mac.finalize().into_bytes()))
96}
97
98/// Verify an HMAC-SHA256 signature (hex-encoded) over a payload with a key.
99///
100/// An empty signature is never valid.
101#[must_use]
102pub fn verify_hmac(payload: &str, signature: &str, key: &[u8]) -> bool {
103    if signature.is_empty() {
104        return false;
105    }
106    let Some(expected) = decode_hex(signature) else {
107        return false;
108    };
109    let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
110        return false;
111    };
112    mac.update(payload.as_bytes());
113    // Constant-time comparison via the MAC's own verifier.
114    mac.verify_slice(&expected).is_ok()
115}
116
117/// A tool capability manifest — declares what a tool can do and who published it.
118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
119pub struct ToolManifest {
120    /// Tool name (e.g., "memory.search").
121    pub tool_name: String,
122    /// Tool version (semver).
123    pub version: String,
124    /// Publisher identity (e.g., "whitemagic-core", "external:acme").
125    pub publisher: String,
126    /// Human-readable description (sanitized).
127    pub description: String,
128    /// Declared effects (reads, writes, spawns).
129    #[serde(default)]
130    pub effects: EffectSummary,
131    /// Declared capabilities (e.g., "read_only", "network_access", "filesystem_write").
132    pub capabilities: Vec<String>,
133    /// Trust level assigned to this tool (0.0 = untrusted, 1.0 = fully trusted).
134    pub trust_level: f32,
135    /// Whether this tool requires human review before execution.
136    pub requires_human_review: bool,
137    /// Manifest creation timestamp (Unix seconds).
138    pub created_at: i64,
139    /// Signature over the manifest content: `ed25519:<hex>` (preferred) or
140    /// legacy bare-hex HMAC-SHA256.
141    #[serde(default)]
142    pub signature: String,
143}
144
145/// A compact summary of effects for serialization in manifests.
146#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
147pub struct EffectSummary {
148    /// Resources the tool reads.
149    #[serde(default)]
150    pub reads: Vec<String>,
151    /// Resources the tool writes.
152    #[serde(default)]
153    pub writes: Vec<String>,
154    /// Whether the tool can spawn subprocesses.
155    #[serde(default)]
156    pub spawns: bool,
157}
158
159impl EffectSummary {
160    /// Create from an `EffectRow`.
161    #[must_use]
162    pub fn from_effect_row(effects: &EffectRow) -> Self {
163        Self {
164            reads: effects.reads.iter().map(|r| format!("{r:?}")).collect(),
165            writes: effects.writes.iter().map(|r| format!("{r:?}")).collect(),
166            spawns: effects.spawns,
167        }
168    }
169
170    /// Whether this manifest declares any destructive (write) effects.
171    #[must_use]
172    pub fn has_destructive_effects(&self) -> bool {
173        !self.writes.is_empty() || self.spawns
174    }
175}
176
177impl ToolManifest {
178    /// Create a new unsigned manifest.
179    #[must_use]
180    pub fn new(tool_name: &str, version: &str, publisher: &str, description: &str) -> Self {
181        Self {
182            tool_name: tool_name.to_string(),
183            version: version.to_string(),
184            publisher: publisher.to_string(),
185            description: description.to_string(),
186            effects: EffectSummary::default(),
187            capabilities: Vec::new(),
188            trust_level: 0.5,
189            requires_human_review: false,
190            created_at: chrono::Utc::now().timestamp(),
191            signature: String::new(),
192        }
193    }
194
195    /// Set effects on the manifest.
196    #[must_use]
197    pub fn with_effects(mut self, effects: EffectSummary) -> Self {
198        self.effects = effects;
199        self
200    }
201
202    /// Set capabilities on the manifest.
203    #[must_use]
204    pub fn with_capabilities(mut self, caps: Vec<String>) -> Self {
205        self.capabilities = caps;
206        self
207    }
208
209    /// Set trust level on the manifest.
210    #[must_use]
211    pub const fn with_trust(mut self, trust: f32) -> Self {
212        self.trust_level = trust.clamp(0.0, 1.0);
213        self
214    }
215
216    /// Require human review.
217    #[must_use]
218    pub const fn require_review(mut self) -> Self {
219        self.requires_human_review = true;
220        self
221    }
222
223    /// Compute the payload to sign (all fields except signature).
224    #[must_use]
225    pub fn signing_payload(&self) -> String {
226        // Serialize without the signature field
227        let without_sig = Self {
228            signature: String::new(),
229            ..self.clone()
230        };
231        serde_json::to_string(&without_sig).unwrap_or_default()
232    }
233
234    /// Sign the manifest with an HMAC key.
235    ///
236    /// Returns a new manifest with the signature set.
237    #[must_use]
238    pub fn sign(mut self, key: &[u8]) -> Self {
239        let payload = self.signing_payload();
240        if let Some(sig) = sign_hmac(&payload, key) {
241            self.signature = sig;
242        }
243        self
244    }
245
246    /// Verify the manifest's signature.
247    ///
248    /// Returns true if the signature matches the current content.
249    #[must_use]
250    pub fn verify_signature(&self, key: &[u8]) -> bool {
251        verify_hmac(&self.signing_payload(), &self.signature, key)
252    }
253
254    /// Signature scheme inferred from the stored signature.
255    ///
256    /// `"ed25519"` for `ed25519:<hex>`, `"hmac-sha256"` for bare hex,
257    /// `"none"` when unsigned.
258    #[must_use]
259    pub fn signature_scheme(&self) -> &'static str {
260        if self.signature.starts_with(ED25519_SIG_PREFIX) {
261            "ed25519"
262        } else if self.signature.is_empty() {
263            "none"
264        } else {
265            "hmac-sha256"
266        }
267    }
268
269    /// Sign the manifest with an Ed25519 key (PLAN_F F-2).
270    ///
271    /// Returns a new manifest with an `ed25519:<hex>` signature.
272    #[must_use]
273    pub fn sign_ed25519(mut self, signing_key: &ed25519_dalek::SigningKey) -> Self {
274        self.signature = sign_ed25519(&self.signing_payload(), signing_key);
275        self
276    }
277
278    /// Verify an Ed25519-signed manifest against a public key.
279    #[must_use]
280    pub fn verify_signature_ed25519(&self, verifying_key: &ed25519_dalek::VerifyingKey) -> bool {
281        verify_ed25519(&self.signing_payload(), &self.signature, verifying_key)
282    }
283
284    /// Whether this manifest declares a specific capability.
285    #[must_use]
286    pub fn has_capability(&self, cap: &str) -> bool {
287        self.capabilities.iter().any(|c| c == cap)
288    }
289
290    /// Whether this manifest has destructive effects.
291    #[must_use]
292    pub fn is_destructive(&self) -> bool {
293        self.effects.has_destructive_effects()
294    }
295}
296
297/// Trust scope — defines what external MCP servers are allowed to do.
298#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
299pub struct TrustScope {
300    /// Name of this trust scope (e.g., "default", "strict", "external").
301    pub name: String,
302    /// Minimum trust level required for tool execution.
303    pub min_trust: f32,
304    /// Allowed tool name patterns (empty = all allowed).
305    #[serde(default)]
306    pub allowed_tools: Vec<String>,
307    /// Denied tool name patterns (takes precedence over allowed).
308    #[serde(default)]
309    pub denied_tools: Vec<String>,
310    /// Whether destructive tools are allowed.
311    pub allow_destructive: bool,
312    /// Whether network access tools are allowed.
313    pub allow_network: bool,
314    /// Whether filesystem write tools are allowed.
315    pub allow_filesystem_write: bool,
316    /// Whether human review is required for all tools in this scope.
317    pub require_review: bool,
318    /// Maximum number of tool calls per minute (0 = unlimited).
319    pub max_calls_per_minute: u32,
320}
321
322impl Default for TrustScope {
323    fn default() -> Self {
324        Self {
325            name: "default".into(),
326            min_trust: 0.5,
327            allowed_tools: Vec::new(),
328            denied_tools: Vec::new(),
329            allow_destructive: false,
330            allow_network: false,
331            allow_filesystem_write: false,
332            require_review: false,
333            max_calls_per_minute: 60,
334        }
335    }
336}
337
338impl TrustScope {
339    /// Strict scope for untrusted external servers.
340    #[must_use]
341    pub fn strict() -> Self {
342        Self {
343            name: "strict".into(),
344            min_trust: 0.8,
345            allowed_tools: vec!["memory.search".into(), "memory.recall".into()],
346            denied_tools: vec!["file.".into(), "process.".into(), "network.".into()],
347            allow_destructive: false,
348            allow_network: false,
349            allow_filesystem_write: false,
350            require_review: true,
351            max_calls_per_minute: 10,
352        }
353    }
354
355    /// Permissive scope for trusted internal servers.
356    #[must_use]
357    pub fn permissive() -> Self {
358        Self {
359            name: "permissive".into(),
360            min_trust: 0.3,
361            allow_destructive: true,
362            allow_network: true,
363            allow_filesystem_write: true,
364            require_review: false,
365            max_calls_per_minute: 200,
366            ..Self::default()
367        }
368    }
369
370    /// Check if a tool is allowed under this trust scope.
371    #[must_use]
372    pub fn is_tool_allowed(&self, manifest: &ToolManifest) -> bool {
373        // Check trust level
374        if manifest.trust_level < self.min_trust {
375            return false;
376        }
377
378        // Check denied list
379        if self
380            .denied_tools
381            .iter()
382            .any(|p| manifest.tool_name.starts_with(p))
383        {
384            return false;
385        }
386
387        // Check allowed list (empty = all allowed)
388        if !self.allowed_tools.is_empty()
389            && !self
390                .allowed_tools
391                .iter()
392                .any(|p| manifest.tool_name.starts_with(p))
393        {
394            return false;
395        }
396
397        // Check destructive
398        if manifest.is_destructive() && !self.allow_destructive {
399            return false;
400        }
401
402        // Check network access
403        if manifest.has_capability("network_access") && !self.allow_network {
404            return false;
405        }
406
407        // Check filesystem write
408        if manifest.has_capability("filesystem_write") && !self.allow_filesystem_write {
409            return false;
410        }
411
412        // Check human review requirement
413        if self.require_review && !manifest.requires_human_review {
414            // Tool doesn't declare it needs review, but scope requires it
415            // This is a warning, not a hard block — the caller should enforce review
416        }
417
418        true
419    }
420}
421
422/// Registry of known tool manifests with verification.
423///
424/// Verification dispatches on the signature scheme: `ed25519:<hex>` against
425/// the registry's issuer key (preferred), bare hex against the legacy HMAC
426/// key. A scheme with no configured key fails closed.
427pub struct ToolAttestationRegistry {
428    /// Known manifests keyed by tool name.
429    manifests: ahash::AHashMap<String, ToolManifest>,
430    /// Legacy HMAC signing key (bare-hex signatures).
431    signing_key: Vec<u8>,
432    /// Ed25519 issuer key — the preferred verification key (PLAN_F F-2).
433    ed25519_key: Option<ed25519_dalek::VerifyingKey>,
434    /// Trust scope for external tools.
435    external_scope: TrustScope,
436    /// Set of trusted publishers.
437    trusted_publishers: Vec<String>,
438}
439
440impl ToolAttestationRegistry {
441    /// Create a new registry with the given HMAC signing key (legacy path).
442    #[must_use]
443    pub fn new(signing_key: Vec<u8>) -> Self {
444        Self {
445            manifests: ahash::AHashMap::new(),
446            signing_key,
447            ed25519_key: None,
448            external_scope: TrustScope::default(),
449            trusted_publishers: vec!["whitemagic-core".into()],
450        }
451    }
452
453    /// Create an Ed25519-first registry. `ed25519:<hex>` manifests verify
454    /// against `verifying_key`; legacy HMAC manifests are refused unless a
455    /// legacy key is attached via [`Self::with_legacy_hmac_key`].
456    #[must_use]
457    pub fn new_ed25519(verifying_key: ed25519_dalek::VerifyingKey) -> Self {
458        Self {
459            manifests: ahash::AHashMap::new(),
460            signing_key: Vec::new(),
461            ed25519_key: Some(verifying_key),
462            external_scope: TrustScope::default(),
463            trusted_publishers: vec!["whitemagic-core".into()],
464        }
465    }
466
467    /// Attach an Ed25519 issuer key to this registry.
468    #[must_use]
469    pub const fn with_ed25519_key(mut self, verifying_key: ed25519_dalek::VerifyingKey) -> Self {
470        self.ed25519_key = Some(verifying_key);
471        self
472    }
473
474    /// Attach the legacy HMAC key — migration window for pre-Ed25519 manifests.
475    #[must_use]
476    pub fn with_legacy_hmac_key(mut self, key: Vec<u8>) -> Self {
477        self.signing_key = key;
478        self
479    }
480
481    /// Verify a manifest against this registry's issuer keys, failing closed
482    /// when the signature's scheme has no configured key.
483    #[must_use]
484    fn verify_manifest(&self, manifest: &ToolManifest) -> bool {
485        match manifest.signature_scheme() {
486            "ed25519" => self
487                .ed25519_key
488                .as_ref()
489                .is_some_and(|key| manifest.verify_signature_ed25519(key)),
490            "hmac-sha256" => {
491                !self.signing_key.is_empty() && manifest.verify_signature(&self.signing_key)
492            }
493            _ => false,
494        }
495    }
496
497    /// Set the trust scope for external tools.
498    #[must_use]
499    pub fn with_external_scope(mut self, scope: TrustScope) -> Self {
500        self.external_scope = scope;
501        self
502    }
503
504    /// Add a trusted publisher.
505    pub fn trust_publisher(&mut self, publisher: &str) {
506        if !self.trusted_publishers.contains(&publisher.to_string()) {
507            self.trusted_publishers.push(publisher.to_string());
508        }
509    }
510
511    /// Register a tool manifest.
512    ///
513    /// Verifies the manifest's signature before registering. Returns false
514    /// if the signature is invalid.
515    pub fn register(&mut self, manifest: ToolManifest) -> bool {
516        // Verify signature against the scheme's configured issuer key.
517        if !self.verify_manifest(&manifest) {
518            return false;
519        }
520
521        // Check publisher is trusted
522        if !self.trusted_publishers.contains(&manifest.publisher) {
523            return false;
524        }
525
526        self.manifests.insert(manifest.tool_name.clone(), manifest);
527        true
528    }
529
530    /// Register a tool manifest without signature verification (for self-signed tools).
531    ///
532    /// The manifest must still come from a trusted publisher.
533    pub fn register_unsigned(&mut self, manifest: ToolManifest) -> bool {
534        if !self.trusted_publishers.contains(&manifest.publisher) {
535            return false;
536        }
537        self.manifests.insert(manifest.tool_name.clone(), manifest);
538        true
539    }
540
541    /// Get a tool's manifest.
542    #[must_use]
543    pub fn get(&self, tool_name: &str) -> Option<&ToolManifest> {
544        self.manifests.get(tool_name)
545    }
546
547    /// Check if a tool is allowed under the current trust scope.
548    #[must_use]
549    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
550        let Some(manifest) = self.manifests.get(tool_name) else {
551            return false; // Unknown tools are not allowed
552        };
553
554        // Internal tools (from whitemagic-core) bypass external scope
555        if manifest.publisher == "whitemagic-core" {
556            return true;
557        }
558
559        // External tools must pass the trust scope
560        self.external_scope.is_tool_allowed(manifest)
561    }
562
563    /// Verify a manifest's provenance (signature + publisher).
564    #[must_use]
565    pub fn verify_provenance(&self, manifest: &ToolManifest) -> bool {
566        self.verify_manifest(manifest) && self.trusted_publishers.contains(&manifest.publisher)
567    }
568
569    /// List all registered tool names.
570    #[must_use]
571    pub fn registered_tools(&self) -> Vec<String> {
572        self.manifests.keys().cloned().collect()
573    }
574
575    /// Number of registered tools.
576    #[must_use]
577    pub fn len(&self) -> usize {
578        self.manifests.len()
579    }
580
581    /// Whether the registry is empty.
582    #[must_use]
583    pub fn is_empty(&self) -> bool {
584        self.manifests.is_empty()
585    }
586}
587
588/// Compute SHA-256 hash of a manifest (for fingerprinting).
589#[must_use]
590pub fn manifest_hash(manifest: &ToolManifest) -> String {
591    let payload = manifest.signing_payload();
592    let mut hasher = Sha256::new();
593    hasher.update(payload.as_bytes());
594    format!("{:x}", hasher.finalize())
595}
596
597// ── Tests ─────────────────────────────────────────────────────────────
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    const TEST_KEY: &[u8] = b"test_signing_key_123";
604
605    fn make_manifest(name: &str, publisher: &str) -> ToolManifest {
606        ToolManifest::new(name, "1.0.0", publisher, "A test tool")
607            .with_trust(0.8)
608            .with_capabilities(vec!["read_only".into()])
609    }
610
611    #[test]
612    fn manifest_sign_and_verify() {
613        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
614        assert!(
615            manifest.verify_signature(TEST_KEY),
616            "Signed manifest should verify"
617        );
618    }
619
620    #[test]
621    fn manifest_tamper_detected() {
622        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
623        let tampered = ToolManifest {
624            description: "Tampered description".into(),
625            ..manifest
626        };
627        assert!(
628            !tampered.verify_signature(TEST_KEY),
629            "Tampered manifest should fail verification"
630        );
631    }
632
633    #[test]
634    fn manifest_unsigned_fails_verification() {
635        let manifest = make_manifest("memory.search", "whitemagic-core");
636        assert!(!manifest.verify_signature(TEST_KEY));
637    }
638
639    #[test]
640    fn manifest_wrong_key_fails() {
641        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
642        assert!(!manifest.verify_signature(b"wrong_key"));
643    }
644
645    #[test]
646    fn manifest_has_capability() {
647        let manifest = make_manifest("memory.search", "whitemagic-core")
648            .with_capabilities(vec!["read_only".into(), "search".into()]);
649        assert!(manifest.has_capability("read_only"));
650        assert!(manifest.has_capability("search"));
651        assert!(!manifest.has_capability("network_access"));
652    }
653
654    #[test]
655    fn manifest_destructive_detection() {
656        let manifest = ToolManifest::new("file.write", "1.0.0", "whitemagic-core", "Write file")
657            .with_effects(EffectSummary {
658                writes: vec!["Filesystem".into()],
659                ..Default::default()
660            });
661        assert!(manifest.is_destructive());
662
663        let read_only = ToolManifest::new("memory.search", "1.0.0", "whitemagic-core", "Search")
664            .with_effects(EffectSummary {
665                reads: vec!["Galaxy".into()],
666                ..Default::default()
667            });
668        assert!(!read_only.is_destructive());
669    }
670
671    #[test]
672    fn trust_scope_default_allows_trusted() {
673        let scope = TrustScope::default();
674        let manifest = make_manifest("memory.search", "whitemagic-core").with_trust(0.6);
675        assert!(scope.is_tool_allowed(&manifest));
676    }
677
678    #[test]
679    fn trust_scope_blocks_low_trust() {
680        let scope = TrustScope::default();
681        let manifest = make_manifest("memory.search", "external").with_trust(0.2);
682        assert!(!scope.is_tool_allowed(&manifest));
683    }
684
685    #[test]
686    fn trust_scope_strict_blocks_destructive() {
687        let scope = TrustScope::strict();
688        let manifest = ToolManifest::new("file.write", "1.0.0", "external", "Write file")
689            .with_trust(0.9)
690            .with_effects(EffectSummary {
691                writes: vec!["Filesystem".into()],
692                ..Default::default()
693            });
694        assert!(!scope.is_tool_allowed(&manifest));
695    }
696
697    #[test]
698    fn trust_scope_strict_blocks_network() {
699        let scope = TrustScope::strict();
700        let manifest = ToolManifest::new("http.fetch", "1.0.0", "external", "Fetch URL")
701            .with_trust(0.9)
702            .with_capabilities(vec!["network_access".into()]);
703        assert!(!scope.is_tool_allowed(&manifest));
704    }
705
706    #[test]
707    fn trust_scope_denied_list_takes_precedence() {
708        let scope = TrustScope {
709            allowed_tools: vec!["memory.".into()],
710            denied_tools: vec!["memory.delete".into()],
711            ..TrustScope::permissive()
712        };
713        let manifest = make_manifest("memory.delete", "external").with_trust(0.9);
714        assert!(!scope.is_tool_allowed(&manifest));
715    }
716
717    #[test]
718    fn registry_register_signed_manifest() {
719        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
720        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
721        assert!(registry.register(manifest));
722        assert_eq!(registry.len(), 1);
723    }
724
725    #[test]
726    fn registry_rejects_invalid_signature() {
727        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
728        let manifest = make_manifest("memory.search", "whitemagic-core").sign(b"wrong_key");
729        assert!(!registry.register(manifest));
730        assert_eq!(registry.len(), 0);
731    }
732
733    #[test]
734    fn registry_rejects_untrusted_publisher() {
735        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
736        let manifest = make_manifest("memory.search", "untrusted").sign(TEST_KEY);
737        assert!(!registry.register(manifest));
738    }
739
740    #[test]
741    fn registry_allows_trusted_publisher() {
742        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
743        registry.trust_publisher("external:acme");
744        let manifest = make_manifest("custom.tool", "external:acme").sign(TEST_KEY);
745        assert!(registry.register(manifest));
746    }
747
748    #[test]
749    fn registry_internal_tools_bypass_scope() {
750        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec())
751            .with_external_scope(TrustScope::strict());
752        let manifest = ToolManifest::new("file.write", "1.0.0", "whitemagic-core", "Write")
753            .with_trust(0.5)
754            .with_effects(EffectSummary {
755                writes: vec!["Filesystem".into()],
756                ..Default::default()
757            })
758            .sign(TEST_KEY);
759        registry.register(manifest);
760
761        // Internal tool should be allowed even under strict scope
762        assert!(registry.is_tool_allowed("file.write"));
763    }
764
765    #[test]
766    fn registry_external_tools_checked_against_scope() {
767        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec())
768            .with_external_scope(TrustScope::strict());
769        registry.trust_publisher("external:acme");
770        let manifest = ToolManifest::new("file.write", "1.0.0", "external:acme", "Write")
771            .with_trust(0.9)
772            .with_effects(EffectSummary {
773                writes: vec!["Filesystem".into()],
774                ..Default::default()
775            })
776            .sign(TEST_KEY);
777        registry.register(manifest);
778
779        // External destructive tool should be blocked by strict scope
780        assert!(!registry.is_tool_allowed("file.write"));
781    }
782
783    #[test]
784    fn registry_unknown_tool_not_allowed() {
785        let registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
786        assert!(!registry.is_tool_allowed("unknown.tool"));
787    }
788
789    #[test]
790    fn registry_verify_provenance() {
791        let registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
792        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
793        assert!(registry.verify_provenance(&manifest));
794
795        let untrusted = make_manifest("memory.search", "untrusted").sign(TEST_KEY);
796        assert!(!registry.verify_provenance(&untrusted));
797    }
798
799    #[test]
800    fn manifest_hash_deterministic() {
801        let m1 = make_manifest("memory.search", "whitemagic-core");
802        let m2 = make_manifest("memory.search", "whitemagic-core");
803        assert_eq!(manifest_hash(&m1), manifest_hash(&m2));
804    }
805
806    #[test]
807    fn manifest_hash_changes_with_content() {
808        let m1 = make_manifest("memory.search", "whitemagic-core");
809        let m2 = make_manifest("memory.search", "whitemagic-core").with_trust(0.9);
810        assert_ne!(manifest_hash(&m1), manifest_hash(&m2));
811    }
812
813    #[test]
814    fn registry_registered_tools_list() {
815        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
816        registry.register(make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY));
817        registry.register(make_manifest("memory.recall", "whitemagic-core").sign(TEST_KEY));
818
819        let tools = registry.registered_tools();
820        assert_eq!(tools.len(), 2);
821        assert!(tools.contains(&"memory.search".to_string()));
822        assert!(tools.contains(&"memory.recall".to_string()));
823    }
824
825    #[test]
826    fn trust_scope_permissive_allows_most() {
827        let scope = TrustScope::permissive();
828        let manifest = ToolManifest::new("file.write", "1.0.0", "external", "Write")
829            .with_trust(0.5)
830            .with_effects(EffectSummary {
831                writes: vec!["Filesystem".into()],
832                ..Default::default()
833            })
834            .with_capabilities(vec!["filesystem_write".into()]);
835        assert!(scope.is_tool_allowed(&manifest));
836    }
837
838    // ── Ed25519 path (PLAN_F F-2) ────────────────────────────────────
839
840    fn ed25519_key() -> ed25519_dalek::SigningKey {
841        ed25519_dalek::SigningKey::from_bytes(&[7u8; 32])
842    }
843
844    #[test]
845    fn manifest_sign_and_verify_ed25519() {
846        let key = ed25519_key();
847        let manifest = make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key);
848        assert!(manifest.verify_signature_ed25519(&key.verifying_key()));
849        assert_eq!(manifest.signature_scheme(), "ed25519");
850    }
851
852    #[test]
853    fn manifest_ed25519_tamper_detected() {
854        let key = ed25519_key();
855        let manifest = make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key);
856        let tampered = ToolManifest {
857            description: "Tampered".into(),
858            ..manifest
859        };
860        assert!(!tampered.verify_signature_ed25519(&key.verifying_key()));
861    }
862
863    #[test]
864    fn manifest_ed25519_wrong_key_fails() {
865        let key = ed25519_key();
866        let other = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
867        let manifest = make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key);
868        assert!(!manifest.verify_signature_ed25519(&other.verifying_key()));
869    }
870
871    #[test]
872    fn manifest_ed25519_signature_not_valid_as_hmac() {
873        let key = ed25519_key();
874        let manifest = make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key);
875        assert!(!manifest.verify_signature(TEST_KEY));
876    }
877
878    #[test]
879    fn manifest_hmac_signature_scheme_unchanged() {
880        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
881        assert_eq!(manifest.signature_scheme(), "hmac-sha256");
882        assert!(manifest.verify_signature(TEST_KEY));
883        assert!(!manifest.verify_signature_ed25519(&ed25519_key().verifying_key()));
884    }
885
886    #[test]
887    fn unsigned_manifest_scheme_none() {
888        let manifest = make_manifest("memory.search", "whitemagic-core");
889        assert_eq!(manifest.signature_scheme(), "none");
890    }
891
892    // ── Registry scheme dispatch / migration (PLAN_F F-2) ────────────
893
894    #[test]
895    fn registry_ed25519_first_registers_ed25519_manifest() {
896        let key = ed25519_key();
897        let mut registry = ToolAttestationRegistry::new_ed25519(key.verifying_key());
898        let manifest = make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key);
899        assert!(registry.register(manifest.clone()));
900        assert!(registry.verify_provenance(&manifest));
901    }
902
903    #[test]
904    fn registry_ed25519_first_rejects_legacy_hmac_without_key() {
905        let key = ed25519_key();
906        let mut registry = ToolAttestationRegistry::new_ed25519(key.verifying_key());
907        let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
908        assert!(
909            !registry.register(manifest),
910            "legacy HMAC must fail closed when no legacy key is configured"
911        );
912        assert_eq!(registry.len(), 0);
913    }
914
915    #[test]
916    fn registry_migration_window_accepts_both_schemes() {
917        let key = ed25519_key();
918        let mut registry = ToolAttestationRegistry::new_ed25519(key.verifying_key())
919            .with_legacy_hmac_key(TEST_KEY.to_vec());
920        let modern = make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key);
921        let legacy = make_manifest("memory.recall", "whitemagic-core").sign(TEST_KEY);
922        assert!(registry.register(modern));
923        assert!(registry.register(legacy));
924        assert_eq!(registry.len(), 2);
925    }
926
927    #[test]
928    fn registry_ed25519_rejects_wrong_issuer_key() {
929        let key = ed25519_key();
930        let other = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
931        let mut registry = ToolAttestationRegistry::new_ed25519(other.verifying_key());
932        let manifest = make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key);
933        assert!(!registry.register(manifest));
934    }
935
936    #[test]
937    fn registry_ed25519_rejects_tampered_manifest() {
938        let key = ed25519_key();
939        let mut registry = ToolAttestationRegistry::new_ed25519(key.verifying_key());
940        let tampered = ToolManifest {
941            description: "Tampered".into(),
942            ..make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key)
943        };
944        assert!(!registry.register(tampered));
945    }
946
947    #[test]
948    fn registry_hmac_only_rejects_ed25519_manifest() {
949        let key = ed25519_key();
950        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
951        let manifest = make_manifest("memory.search", "whitemagic-core").sign_ed25519(&key);
952        assert!(
953            !registry.register(manifest),
954            "an ed25519 manifest needs an ed25519 issuer key"
955        );
956    }
957
958    #[test]
959    fn registry_rejects_unsigned_manifest_fail_closed() {
960        let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
961        let manifest = make_manifest("memory.search", "whitemagic-core");
962        assert!(!registry.register(manifest));
963    }
964
965    #[test]
966    fn verify_hmac_rejects_forgeries_and_malformed_hex() {
967        let good = sign_hmac("payload", TEST_KEY).unwrap();
968        assert!(verify_hmac("payload", &good, TEST_KEY));
969        assert!(!verify_hmac("payload", &good, b"wrong"));
970        assert!(!verify_hmac("payload!", &good, TEST_KEY));
971        assert!(!verify_hmac("payload", "", TEST_KEY));
972        assert!(!verify_hmac("payload", "zz", TEST_KEY));
973    }
974}