1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Verdict {
41 Allowed,
43 Blocked,
45 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#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SigilEnvelope {
67 pub identity: String,
69 pub verdict: Verdict,
71 pub timestamp: String,
73 pub nonce: String,
75 pub signature: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub reason: Option<String>,
80}
81
82impl SigilEnvelope {
83 pub fn canonical_bytes(
88 identity: &str,
89 verdict: &Verdict,
90 timestamp: &str,
91 nonce: &str,
92 ) -> Vec<u8> {
93 let canonical = serde_json::json!({
95 "identity": identity,
96 "nonce": nonce,
97 "timestamp": timestamp,
98 "verdict": verdict.to_string(),
99 });
100 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 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 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, ×tamp, &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 pub fn verify(&self, verifying_key_base64: &str) -> Result<bool> {
155 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 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 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 #[cfg(feature = "registry")]
208 pub async fn verify_with_registry(
209 &self,
210 registry_url: &str,
211 ) -> Result<RegistryVerifyResult> {
212 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 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 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#[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#[derive(Debug)]
297pub struct RegistryVerifyResult {
298 pub valid: bool,
300 pub status: String,
302 pub reason: Option<String>,
304 pub public_key: Option<String>,
306}
307
308#[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
333pub struct SigilKeypair {
340 signing_key: SigningKey,
341}
342
343impl SigilKeypair {
344 pub fn generate() -> Self {
346 let signing_key = SigningKey::generate(&mut OsRng);
347 Self { signing_key }
348 }
349
350 pub fn from_seed(seed: &[u8; 32]) -> Self {
352 Self {
353 signing_key: SigningKey::from_bytes(seed),
354 }
355 }
356
357 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 pub fn verifying_key_bytes(&self) -> [u8; 32] {
367 *self.signing_key.verifying_key().as_bytes()
368 }
369}
370
371#[cfg(test)]
374mod tests {
375 use super::*;
376
377 fn test_keypair() -> SigilKeypair {
378 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 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 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 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 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 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 #[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 #[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 let kp = SigilKeypair::generate();
617 let public_key = kp.verifying_key_base64();
618
619 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 let envelope = SigilEnvelope::sign(&test_id, Verdict::Allowed, None, &kp)
642 .expect("Signing should succeed");
643
644 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}