Skip to main content

sigil_protocol/
sigil_envelope.rs

1//! SIGIL Envelope — per-message DID-bound cryptographic signing.
2//!
3//! Implements the `_sigil` envelope as defined in the SIGIL Protocol
4//! Specification v1.0.0 (§2–§4). Each MCP JSON-RPC 2.0 tool call carries
5//! a `SigilEnvelope` that:
6//!
7//! 1. Asserts the caller's identity as a Decentralised Identifier (DID).
8//! 2. Embeds the real-time policy verdict (`allowed` / `blocked` / `scanned`).
9//! 3. Carries an **Ed25519 digital signature** over the canonical form of the
10//!    envelope, making the identity + verdict non-repudiable.
11//!
12//! # Signing
13//!
14//! ```rust
15//! use sigil_protocol::sigil_envelope::{SigilEnvelope, SigilKeypair, Verdict};
16//!
17//! let keypair = SigilKeypair::generate();
18//! let envelope = SigilEnvelope::sign(
19//!     "did:sigil:parent_01",
20//!     Verdict::Allowed,
21//!     None,
22//!     &keypair,
23//! ).unwrap();
24//! assert!(envelope.verify(&keypair.verifying_key_base64()).unwrap());
25//! ```
26
27use anyhow::{anyhow, Result};
28use base64ct::{Base64UrlUnpadded, Encoding};
29use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
30use rand_core::OsRng;
31use serde::{Deserialize, Serialize};
32
33// ── Verdict ──────────────────────────────────────────────────────────────────
34
35/// The real-time policy decision for a single MCP tool call.
36///
37/// Exactly three values are valid per SIGIL Spec §2.3.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Verdict {
41    /// The gateway verified the identity and permitted the call.
42    Allowed,
43    /// The gateway denied the call. `reason` must be present.
44    Blocked,
45    /// The call is permitted but the payload was inspected (e.g., for PII).
46    Scanned,
47}
48
49impl std::fmt::Display for Verdict {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Verdict::Allowed => write!(f, "allowed"),
53            Verdict::Blocked => write!(f, "blocked"),
54            Verdict::Scanned => write!(f, "scanned"),
55        }
56    }
57}
58
59// ── SigilEnvelope ─────────────────────────────────────────────────────────────
60
61/// The `_sigil` object embedded in every MCP JSON-RPC request's `params`.
62///
63/// All fields except `reason` are required. The `signature` field is a
64/// base64url-encoded Ed25519 signature over the canonical form (§3.1).
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SigilEnvelope {
67    /// DID of the requesting agent (e.g., `did:sigil:parent_01`).
68    pub identity: String,
69    /// Policy verdict for this call.
70    pub verdict: Verdict,
71    /// Signing time, ISO 8601 with millisecond precision (UTC).
72    pub timestamp: String,
73    /// 16-byte cryptographically random nonce, hex-encoded. Prevents replays.
74    pub nonce: String,
75    /// Ed25519 signature over the canonical form, base64url-encoded.
76    pub signature: String,
77    /// Present when verdict is `blocked` or `scanned`.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub reason: Option<String>,
80}
81
82impl SigilEnvelope {
83    /// Produce the canonical byte string for signing/verifying.
84    ///
85    /// Per SIGIL Spec §3.1: fields in lexicographic key order, compact JSON,
86    /// no whitespace, excluding `signature` and `reason`.
87    pub fn canonical_bytes(
88        identity: &str,
89        verdict: &Verdict,
90        timestamp: &str,
91        nonce: &str,
92    ) -> Vec<u8> {
93        // Keys must appear in lexicographic order: identity, nonce, timestamp, verdict
94        let canonical = serde_json::json!({
95            "identity": identity,
96            "nonce": nonce,
97            "timestamp": timestamp,
98            "verdict": verdict.to_string(),
99        });
100        // serde_json preserves insertion order — but we need lexicographic order.
101        // Build the string manually to guarantee determinism across platforms.
102        format!(
103            "{{\"identity\":{},\"nonce\":{},\"timestamp\":{},\"verdict\":{}}}",
104            serde_json::to_string(identity).unwrap(),
105            serde_json::to_string(nonce).unwrap(),
106            serde_json::to_string(timestamp).unwrap(),
107            serde_json::to_string(&canonical["verdict"]).unwrap(),
108        )
109        .into_bytes()
110    }
111
112    /// Sign a new envelope using the given keypair.
113    ///
114    /// Generates a fresh nonce and timestamp automatically.
115    pub fn sign(
116        identity: &str,
117        verdict: Verdict,
118        reason: Option<String>,
119        keypair: &SigilKeypair,
120    ) -> Result<Self> {
121        if verdict == Verdict::Blocked && reason.is_none() {
122            return Err(anyhow!(
123                "SIGIL spec §2.3: reason MUST be present when verdict = blocked"
124            ));
125        }
126
127        let timestamp = chrono::Utc::now()
128            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
129            .to_string();
130
131        // 16-byte cryptographically random nonce
132        let mut nonce_bytes = [0u8; 16];
133        rand_core::RngCore::fill_bytes(&mut OsRng, &mut nonce_bytes);
134        let nonce = hex::encode(nonce_bytes);
135
136        let canonical = Self::canonical_bytes(identity, &verdict, &timestamp, &nonce);
137        let signature_bytes: Signature = keypair.signing_key.sign(&canonical);
138        let signature = Base64UrlUnpadded::encode_string(signature_bytes.to_bytes().as_ref());
139
140        Ok(Self {
141            identity: identity.to_string(),
142            verdict,
143            timestamp,
144            nonce,
145            signature,
146            reason,
147        })
148    }
149
150    /// Verify the envelope signature against an Ed25519 public key (base64url).
151    ///
152    /// Returns `Ok(true)` if the signature is valid, `Ok(false)` if invalid,
153    /// or `Err` if the public key or signature cannot be decoded.
154    pub fn verify(&self, verifying_key_base64: &str) -> Result<bool> {
155        // Decode public key
156        let key_bytes = Base64UrlUnpadded::decode_vec(verifying_key_base64)
157            .map_err(|e| anyhow!("Failed to decode verifying key: {e}"))?;
158        let key_array: [u8; 32] = key_bytes
159            .try_into()
160            .map_err(|_| anyhow!("Verifying key must be exactly 32 bytes"))?;
161        let verifying_key = VerifyingKey::from_bytes(&key_array)
162            .map_err(|e| anyhow!("Invalid Ed25519 public key: {e}"))?;
163
164        // Decode signature
165        let sig_bytes = Base64UrlUnpadded::decode_vec(&self.signature)
166            .map_err(|e| anyhow!("Failed to decode signature: {e}"))?;
167        let sig_array: [u8; 64] = sig_bytes
168            .try_into()
169            .map_err(|_| anyhow!("Signature must be exactly 64 bytes"))?;
170        let signature = Signature::from_bytes(&sig_array);
171
172        // Reconstruct canonical bytes
173        let canonical =
174            Self::canonical_bytes(&self.identity, &self.verdict, &self.timestamp, &self.nonce);
175
176        Ok(verifying_key.verify(&canonical, &signature).is_ok())
177    }
178
179    /// Verify this envelope end-to-end against a live SIGIL Registry.
180    ///
181    /// This method:
182    /// 1. Calls `GET {registry_url}/resolve/{did}` to fetch the DID document.
183    /// 2. Rejects the envelope if the DID is unknown (`404`) or revoked.
184    /// 3. Uses the **registry-resolved** public key to verify the Ed25519 signature.
185    ///
186    /// This is the **authoritative** verification path — it catches compromised or
187    /// revoked keys that a local-only `verify()` call cannot detect.
188    ///
189    /// # Feature gate
190    ///
191    /// This method is only available when compiled with `features = ["registry"]`.
192    /// Add to your `Cargo.toml`:
193    /// ```toml
194    /// sigil-protocol = { version = "0.1", features = ["registry"] }
195    /// ```
196    ///
197    /// # Example
198    ///
199    /// ```rust,no_run
200    /// # tokio_test::block_on(async {
201    /// // Assumes envelope was previously signed with a registered DID keypair
202    /// // let result = envelope.verify_with_registry("https://registry.sigil-protocol.org").await?;
203    /// // assert!(result.valid);
204    /// // assert_eq!(result.status, "active");
205    /// # })
206    /// ```
207    #[cfg(feature = "registry")]
208    pub async fn verify_with_registry(
209        &self,
210        registry_url: &str,
211    ) -> Result<RegistryVerifyResult> {
212        // 1. Resolve the DID from the registry
213        let url = format!(
214            "{}/resolve/{}",
215            registry_url.trim_end_matches('/'),
216            urlencoding_encode(&self.identity)
217        );
218
219        let response = reqwest::get(&url)
220            .await
221            .map_err(|e| anyhow!("Registry request failed: {e}"))?;
222
223        let status_code = response.status();
224
225        if status_code == reqwest::StatusCode::NOT_FOUND {
226            return Ok(RegistryVerifyResult {
227                valid: false,
228                status: "not_found".into(),
229                reason: Some(format!("DID '{}' is not registered", self.identity)),
230                public_key: None,
231            });
232        }
233
234        if !status_code.is_success() {
235            return Err(anyhow!(
236                "Registry returned unexpected status {}: {}",
237                status_code,
238                url
239            ));
240        }
241
242        let record: RegistryRecord = response
243            .json()
244            .await
245            .map_err(|e| anyhow!("Failed to parse registry response: {e}"))?;
246
247        // 2. Reject revoked identities immediately
248        if record.status == "revoked" {
249            return Ok(RegistryVerifyResult {
250                valid: false,
251                status: "revoked".into(),
252                reason: Some(format!(
253                    "DID '{}' has been revoked{}",
254                    self.identity,
255                    record
256                        .revoked_at
257                        .map(|t| format!(" at {t}"))
258                        .unwrap_or_default()
259                )),
260                public_key: Some(record.public_key),
261            });
262        }
263
264        // 3. Verify the Ed25519 signature using the registry's public key
265        let sig_valid = self.verify(&record.public_key)?;
266
267        Ok(RegistryVerifyResult {
268            valid: sig_valid,
269            status: record.status,
270            reason: if sig_valid {
271                None
272            } else {
273                Some("Ed25519 signature verification failed".into())
274            },
275            public_key: Some(record.public_key),
276        })
277    }
278}
279
280// ── Registry types ────────────────────────────────────────────────────────────
281
282/// DID document returned by `GET /resolve/:did` on a SIGIL Registry.
283#[derive(Debug, Deserialize)]
284pub struct RegistryRecord {
285    pub did: String,
286    pub status: String,
287    pub public_key: String,
288    pub namespace: String,
289    pub label: Option<String>,
290    pub created_at: String,
291    pub updated_at: String,
292    pub revoked_at: Option<String>,
293}
294
295/// Result of [`SigilEnvelope::verify_with_registry`].
296#[derive(Debug)]
297pub struct RegistryVerifyResult {
298    /// `true` if the DID is active and the signature is cryptographically valid.
299    pub valid: bool,
300    /// DID status from the registry: `"active"` or `"revoked"`.
301    pub status: String,
302    /// Human-readable explanation when `valid = false`.
303    pub reason: Option<String>,
304    /// The base64url public key from the registry (useful for caching).
305    pub public_key: Option<String>,
306}
307
308// ── URL encoding helper (no dep needed) ──────────────────────────────────────
309
310#[cfg(feature = "registry")]
311fn urlencoding_encode(s: &str) -> String {
312    s.chars()
313        .flat_map(|c| match c {
314            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
315                vec![c]
316            }
317            c => {
318                let mut buf = [0u8; 4];
319                let bytes = c.encode_utf8(&mut buf);
320                bytes.bytes().flat_map(|b| {
321                    vec![
322                        '%',
323                        char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase(),
324                        char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase(),
325                    ]
326                }).collect::<Vec<_>>()
327            }
328        })
329        .collect()
330}
331
332
333// ── SigilKeypair ─────────────────────────────────────────────────────────────
334
335/// An Ed25519 keypair for signing SIGIL envelopes.
336///
337/// The signing key is kept in memory only. In production, the private key
338/// MUST be stored in a secure enclave or OS keychain (SIGIL Spec §11.4).
339pub struct SigilKeypair {
340    signing_key: SigningKey,
341}
342
343impl SigilKeypair {
344    /// Generate a new random Ed25519 keypair using the OS random source.
345    pub fn generate() -> Self {
346        let signing_key = SigningKey::generate(&mut OsRng);
347        Self { signing_key }
348    }
349
350    /// Load a keypair from a raw 32-byte seed (private key scalar).
351    pub fn from_seed(seed: &[u8; 32]) -> Self {
352        Self {
353            signing_key: SigningKey::from_bytes(seed),
354        }
355    }
356
357    /// Export the public verifying key as base64url (no padding).
358    ///
359    /// This is the value to store in the DID Document and SIGIL Registry.
360    pub fn verifying_key_base64(&self) -> String {
361        let vk: VerifyingKey = self.signing_key.verifying_key();
362        Base64UrlUnpadded::encode_string(vk.as_bytes())
363    }
364
365    /// Export the raw verifying key bytes (32 bytes).
366    pub fn verifying_key_bytes(&self) -> [u8; 32] {
367        *self.signing_key.verifying_key().as_bytes()
368    }
369}
370
371// ── Tests ─────────────────────────────────────────────────────────────────────
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn test_keypair() -> SigilKeypair {
378        // Fixed seed for deterministic tests
379        let seed = [42u8; 32];
380        SigilKeypair::from_seed(&seed)
381    }
382
383    #[test]
384    fn verdict_display() {
385        assert_eq!(Verdict::Allowed.to_string(), "allowed");
386        assert_eq!(Verdict::Blocked.to_string(), "blocked");
387        assert_eq!(Verdict::Scanned.to_string(), "scanned");
388    }
389
390    #[test]
391    fn verdict_serializes_lowercase() {
392        let json = serde_json::to_string(&Verdict::Allowed).unwrap();
393        assert_eq!(json, "\"allowed\"");
394        let json = serde_json::to_string(&Verdict::Blocked).unwrap();
395        assert_eq!(json, "\"blocked\"");
396    }
397
398    #[test]
399    fn canonical_bytes_are_deterministic() {
400        let a = SigilEnvelope::canonical_bytes(
401            "did:sigil:parent_01",
402            &Verdict::Allowed,
403            "2026-02-21T17:54:44.123Z",
404            "a3f82c1d9b7e04f5",
405        );
406        let b = SigilEnvelope::canonical_bytes(
407            "did:sigil:parent_01",
408            &Verdict::Allowed,
409            "2026-02-21T17:54:44.123Z",
410            "a3f82c1d9b7e04f5",
411        );
412        assert_eq!(a, b);
413    }
414
415    #[test]
416    fn canonical_bytes_are_lexicographically_ordered() {
417        let bytes = SigilEnvelope::canonical_bytes(
418            "did:sigil:parent_01",
419            &Verdict::Allowed,
420            "2026-02-21T17:54:44.123Z",
421            "a3f82c1d9b7e04f5",
422        );
423        let s = String::from_utf8(bytes).unwrap();
424        // Keys must appear in order: identity, nonce, timestamp, verdict
425        let id_pos = s.find("identity").unwrap();
426        let nonce_pos = s.find("nonce").unwrap();
427        let ts_pos = s.find("timestamp").unwrap();
428        let verdict_pos = s.find("verdict").unwrap();
429        assert!(id_pos < nonce_pos);
430        assert!(nonce_pos < ts_pos);
431        assert!(ts_pos < verdict_pos);
432    }
433
434    #[test]
435    fn sign_and_verify_allowed() {
436        let kp = test_keypair();
437        let vk = kp.verifying_key_base64();
438        let envelope =
439            SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
440
441        assert_eq!(envelope.identity, "did:sigil:parent_01");
442        assert_eq!(envelope.verdict, Verdict::Allowed);
443        assert!(envelope.reason.is_none());
444        assert!(envelope.verify(&vk).unwrap(), "Valid signature should verify");
445    }
446
447    #[test]
448    fn sign_and_verify_blocked_with_reason() {
449        let kp = test_keypair();
450        let vk = kp.verifying_key_base64();
451        let envelope = SigilEnvelope::sign(
452            "did:sigil:child_02",
453            Verdict::Blocked,
454            Some("Insufficient trust level".into()),
455            &kp,
456        )
457        .unwrap();
458
459        assert_eq!(envelope.verdict, Verdict::Blocked);
460        assert_eq!(envelope.reason.as_deref(), Some("Insufficient trust level"));
461        assert!(envelope.verify(&vk).unwrap());
462    }
463
464    #[test]
465    fn blocked_without_reason_is_rejected() {
466        let kp = test_keypair();
467        let result = SigilEnvelope::sign("did:sigil:agent", Verdict::Blocked, None, &kp);
468        assert!(result.is_err());
469        assert!(result
470            .unwrap_err()
471            .to_string()
472            .contains("reason MUST be present"));
473    }
474
475    #[test]
476    fn tampered_identity_fails_verification() {
477        let kp = test_keypair();
478        let vk = kp.verifying_key_base64();
479        let mut envelope =
480            SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
481
482        // Attacker tries to change the identity after signing
483        envelope.identity = "did:sigil:attacker".to_string();
484
485        assert!(
486            !envelope.verify(&vk).unwrap(),
487            "Tampered identity must fail verification"
488        );
489    }
490
491    #[test]
492    fn tampered_verdict_fails_verification() {
493        let kp = test_keypair();
494        let vk = kp.verifying_key_base64();
495        let mut envelope =
496            SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
497
498        // Attacker tries to change allowed → blocked (unlikely, but covered)
499        // Actually testing allowed → scanned
500        envelope.verdict = Verdict::Scanned;
501
502        assert!(
503            !envelope.verify(&vk).unwrap(),
504            "Tampered verdict must fail verification"
505        );
506    }
507
508    #[test]
509    fn wrong_keypair_fails_verification() {
510        let kp1 = SigilKeypair::generate();
511        let kp2 = SigilKeypair::generate();
512
513        let envelope =
514            SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp1).unwrap();
515
516        // Try to verify with a different keypair's public key
517        let wrong_vk = kp2.verifying_key_base64();
518        assert!(
519            !envelope.verify(&wrong_vk).unwrap(),
520            "Wrong keypair must fail verification"
521        );
522    }
523
524    #[test]
525    fn nonce_is_16_bytes_hex() {
526        let kp = test_keypair();
527        let envelope =
528            SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
529        // 16 bytes = 32 hex characters
530        assert_eq!(envelope.nonce.len(), 32);
531        assert!(
532            envelope.nonce.chars().all(|c| c.is_ascii_hexdigit()),
533            "Nonce must be hex-encoded"
534        );
535    }
536
537    #[test]
538    fn nonces_are_unique() {
539        let kp = test_keypair();
540        let e1 =
541            SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
542        let e2 =
543            SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
544        assert_ne!(e1.nonce, e2.nonce, "Each envelope must have a unique nonce");
545    }
546
547    #[test]
548    fn envelope_roundtrips_json() {
549        let kp = test_keypair();
550        let vk = kp.verifying_key_base64();
551        let original =
552            SigilEnvelope::sign("did:sigil:parent_01", Verdict::Scanned, Some("PII detected".into()), &kp)
553                .unwrap();
554
555        let json = serde_json::to_string(&original).unwrap();
556        let deserialized: SigilEnvelope = serde_json::from_str(&json).unwrap();
557
558        assert_eq!(deserialized.identity, original.identity);
559        assert_eq!(deserialized.verdict, original.verdict);
560        assert_eq!(deserialized.signature, original.signature);
561        assert!(deserialized.verify(&vk).unwrap(), "Deserialized envelope must verify");
562    }
563
564    #[test]
565    fn keypair_from_seed_is_deterministic() {
566        let seed = [99u8; 32];
567        let kp1 = SigilKeypair::from_seed(&seed);
568        let kp2 = SigilKeypair::from_seed(&seed);
569        assert_eq!(kp1.verifying_key_base64(), kp2.verifying_key_base64());
570    }
571
572    // ── Live integration tests ─────────────────────────────────────────────────
573    // These tests hit the live SIGIL Registry at registry.sigil-protocol.org.
574    // They are marked #[ignore] so they do NOT run in normal `cargo test`.
575    // Run them explicitly with:
576    //   cargo test --features registry -- --ignored
577    //   cargo test --features registry live_ -- --ignored --nocapture
578
579    /// Verify the live registry health endpoint is reachable.
580    #[cfg(feature = "registry")]
581    #[tokio::test]
582    #[ignore = "requires network access to registry.sigil-protocol.org"]
583    async fn live_registry_health_check() {
584        let resp = reqwest::get("https://registry.sigil-protocol.org/health")
585            .await
586            .expect("Registry should be reachable");
587        assert!(resp.status().is_success(), "Health check should return 200");
588        let body: serde_json::Value = resp.json().await.unwrap();
589        assert_eq!(body["status"], "ok");
590        assert_eq!(body["service"], "sigil-registry");
591        println!("Registry version: {}", body["version"]);
592    }
593
594    /// Full end-to-end round-trip:
595    /// 1. Generate a fresh Ed25519 keypair
596    /// 2. Register it with the live SIGIL Registry
597    /// 3. Sign an envelope with the private key
598    /// 4. Verify the envelope against the live registry (public key resolved remotely)
599    #[cfg(feature = "registry")]
600    #[tokio::test]
601    #[ignore = "requires network access and writes to registry.sigil-protocol.org"]
602    async fn live_sign_register_and_verify_with_registry() {
603        use crate::sigil_envelope::{SigilEnvelope, SigilKeypair, Verdict};
604
605        let test_id = format!(
606            "did:sigil:test_{}",
607            &hex::encode({
608                use rand_core::RngCore;
609                let mut b = [0u8; 4];
610                rand_core::OsRng.fill_bytes(&mut b);
611                b
612            })
613        );
614
615        // 1. Generate a fresh keypair
616        let kp = SigilKeypair::generate();
617        let public_key = kp.verifying_key_base64();
618
619        // 2. Register the DID with the live registry
620        let client = reqwest::Client::new();
621        let reg_resp = client
622            .post("https://registry.sigil-protocol.org/register")
623            .json(&serde_json::json!({
624                "did": test_id,
625                "public_key": public_key,
626                "namespace": "test",
627                "label": "Live integration test — auto-generated"
628            }))
629            .send()
630            .await
631            .expect("Register request should succeed");
632
633        assert_eq!(
634            reg_resp.status(),
635            reqwest::StatusCode::CREATED,
636            "DID registration should return 201"
637        );
638        println!("Registered: {test_id}");
639
640        // 3. Sign an envelope using the private key
641        let envelope = SigilEnvelope::sign(&test_id, Verdict::Allowed, None, &kp)
642            .expect("Signing should succeed");
643
644        // 4. Verify end-to-end against the live registry
645        let result = envelope
646            .verify_with_registry("https://registry.sigil-protocol.org")
647            .await
648            .expect("verify_with_registry should not error");
649
650        assert!(
651            result.valid,
652            "Live round-trip verification failed: {:?}",
653            result.reason
654        );
655        assert_eq!(result.status, "active");
656        println!("✅ Live end-to-end verification passed for {test_id}");
657    }
658}