use anyhow::{anyhow, Result};
use base64ct::{Base64UrlUnpadded, Encoding};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Allowed,
Blocked,
Scanned,
}
impl std::fmt::Display for Verdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Verdict::Allowed => write!(f, "allowed"),
Verdict::Blocked => write!(f, "blocked"),
Verdict::Scanned => write!(f, "scanned"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SigilEnvelope {
pub identity: String,
pub verdict: Verdict,
pub timestamp: String,
pub nonce: String,
pub signature: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl SigilEnvelope {
pub fn canonical_bytes(
identity: &str,
verdict: &Verdict,
timestamp: &str,
nonce: &str,
) -> Vec<u8> {
let canonical = serde_json::json!({
"identity": identity,
"nonce": nonce,
"timestamp": timestamp,
"verdict": verdict.to_string(),
});
format!(
"{{\"identity\":{},\"nonce\":{},\"timestamp\":{},\"verdict\":{}}}",
serde_json::to_string(identity).unwrap(),
serde_json::to_string(nonce).unwrap(),
serde_json::to_string(timestamp).unwrap(),
serde_json::to_string(&canonical["verdict"]).unwrap(),
)
.into_bytes()
}
pub fn sign(
identity: &str,
verdict: Verdict,
reason: Option<String>,
keypair: &SigilKeypair,
) -> Result<Self> {
if verdict == Verdict::Blocked && reason.is_none() {
return Err(anyhow!(
"SIGIL spec §2.3: reason MUST be present when verdict = blocked"
));
}
let timestamp = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
let mut nonce_bytes = [0u8; 16];
rand_core::RngCore::fill_bytes(&mut OsRng, &mut nonce_bytes);
let nonce = hex::encode(nonce_bytes);
let canonical = Self::canonical_bytes(identity, &verdict, ×tamp, &nonce);
let signature_bytes: Signature = keypair.signing_key.sign(&canonical);
let signature = Base64UrlUnpadded::encode_string(signature_bytes.to_bytes().as_ref());
Ok(Self {
identity: identity.to_string(),
verdict,
timestamp,
nonce,
signature,
reason,
})
}
pub fn verify(&self, verifying_key_base64: &str) -> Result<bool> {
let key_bytes = Base64UrlUnpadded::decode_vec(verifying_key_base64)
.map_err(|e| anyhow!("Failed to decode verifying key: {e}"))?;
let key_array: [u8; 32] = key_bytes
.try_into()
.map_err(|_| anyhow!("Verifying key must be exactly 32 bytes"))?;
let verifying_key = VerifyingKey::from_bytes(&key_array)
.map_err(|e| anyhow!("Invalid Ed25519 public key: {e}"))?;
let sig_bytes = Base64UrlUnpadded::decode_vec(&self.signature)
.map_err(|e| anyhow!("Failed to decode signature: {e}"))?;
let sig_array: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| anyhow!("Signature must be exactly 64 bytes"))?;
let signature = Signature::from_bytes(&sig_array);
let canonical =
Self::canonical_bytes(&self.identity, &self.verdict, &self.timestamp, &self.nonce);
Ok(verifying_key.verify(&canonical, &signature).is_ok())
}
#[cfg(feature = "registry")]
pub async fn verify_with_registry(
&self,
registry_url: &str,
) -> Result<RegistryVerifyResult> {
let url = format!(
"{}/resolve/{}",
registry_url.trim_end_matches('/'),
urlencoding_encode(&self.identity)
);
let response = reqwest::get(&url)
.await
.map_err(|e| anyhow!("Registry request failed: {e}"))?;
let status_code = response.status();
if status_code == reqwest::StatusCode::NOT_FOUND {
return Ok(RegistryVerifyResult {
valid: false,
status: "not_found".into(),
reason: Some(format!("DID '{}' is not registered", self.identity)),
public_key: None,
});
}
if !status_code.is_success() {
return Err(anyhow!(
"Registry returned unexpected status {}: {}",
status_code,
url
));
}
let record: RegistryRecord = response
.json()
.await
.map_err(|e| anyhow!("Failed to parse registry response: {e}"))?;
if record.status == "revoked" {
return Ok(RegistryVerifyResult {
valid: false,
status: "revoked".into(),
reason: Some(format!(
"DID '{}' has been revoked{}",
self.identity,
record
.revoked_at
.map(|t| format!(" at {t}"))
.unwrap_or_default()
)),
public_key: Some(record.public_key),
});
}
let sig_valid = self.verify(&record.public_key)?;
Ok(RegistryVerifyResult {
valid: sig_valid,
status: record.status,
reason: if sig_valid {
None
} else {
Some("Ed25519 signature verification failed".into())
},
public_key: Some(record.public_key),
})
}
}
#[derive(Debug, Deserialize)]
pub struct RegistryRecord {
pub did: String,
pub status: String,
pub public_key: String,
pub namespace: String,
pub label: Option<String>,
pub created_at: String,
pub updated_at: String,
pub revoked_at: Option<String>,
}
#[derive(Debug)]
pub struct RegistryVerifyResult {
pub valid: bool,
pub status: String,
pub reason: Option<String>,
pub public_key: Option<String>,
}
#[cfg(feature = "registry")]
fn urlencoding_encode(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
vec![c]
}
c => {
let mut buf = [0u8; 4];
let bytes = c.encode_utf8(&mut buf);
bytes.bytes().flat_map(|b| {
vec![
'%',
char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase(),
char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase(),
]
}).collect::<Vec<_>>()
}
})
.collect()
}
pub struct SigilKeypair {
signing_key: SigningKey,
}
impl SigilKeypair {
pub fn generate() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
Self { signing_key }
}
pub fn from_seed(seed: &[u8; 32]) -> Self {
Self {
signing_key: SigningKey::from_bytes(seed),
}
}
pub fn verifying_key_base64(&self) -> String {
let vk: VerifyingKey = self.signing_key.verifying_key();
Base64UrlUnpadded::encode_string(vk.as_bytes())
}
pub fn verifying_key_bytes(&self) -> [u8; 32] {
*self.signing_key.verifying_key().as_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_keypair() -> SigilKeypair {
let seed = [42u8; 32];
SigilKeypair::from_seed(&seed)
}
#[test]
fn verdict_display() {
assert_eq!(Verdict::Allowed.to_string(), "allowed");
assert_eq!(Verdict::Blocked.to_string(), "blocked");
assert_eq!(Verdict::Scanned.to_string(), "scanned");
}
#[test]
fn verdict_serializes_lowercase() {
let json = serde_json::to_string(&Verdict::Allowed).unwrap();
assert_eq!(json, "\"allowed\"");
let json = serde_json::to_string(&Verdict::Blocked).unwrap();
assert_eq!(json, "\"blocked\"");
}
#[test]
fn canonical_bytes_are_deterministic() {
let a = SigilEnvelope::canonical_bytes(
"did:sigil:parent_01",
&Verdict::Allowed,
"2026-02-21T17:54:44.123Z",
"a3f82c1d9b7e04f5",
);
let b = SigilEnvelope::canonical_bytes(
"did:sigil:parent_01",
&Verdict::Allowed,
"2026-02-21T17:54:44.123Z",
"a3f82c1d9b7e04f5",
);
assert_eq!(a, b);
}
#[test]
fn canonical_bytes_are_lexicographically_ordered() {
let bytes = SigilEnvelope::canonical_bytes(
"did:sigil:parent_01",
&Verdict::Allowed,
"2026-02-21T17:54:44.123Z",
"a3f82c1d9b7e04f5",
);
let s = String::from_utf8(bytes).unwrap();
let id_pos = s.find("identity").unwrap();
let nonce_pos = s.find("nonce").unwrap();
let ts_pos = s.find("timestamp").unwrap();
let verdict_pos = s.find("verdict").unwrap();
assert!(id_pos < nonce_pos);
assert!(nonce_pos < ts_pos);
assert!(ts_pos < verdict_pos);
}
#[test]
fn sign_and_verify_allowed() {
let kp = test_keypair();
let vk = kp.verifying_key_base64();
let envelope =
SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
assert_eq!(envelope.identity, "did:sigil:parent_01");
assert_eq!(envelope.verdict, Verdict::Allowed);
assert!(envelope.reason.is_none());
assert!(envelope.verify(&vk).unwrap(), "Valid signature should verify");
}
#[test]
fn sign_and_verify_blocked_with_reason() {
let kp = test_keypair();
let vk = kp.verifying_key_base64();
let envelope = SigilEnvelope::sign(
"did:sigil:child_02",
Verdict::Blocked,
Some("Insufficient trust level".into()),
&kp,
)
.unwrap();
assert_eq!(envelope.verdict, Verdict::Blocked);
assert_eq!(envelope.reason.as_deref(), Some("Insufficient trust level"));
assert!(envelope.verify(&vk).unwrap());
}
#[test]
fn blocked_without_reason_is_rejected() {
let kp = test_keypair();
let result = SigilEnvelope::sign("did:sigil:agent", Verdict::Blocked, None, &kp);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("reason MUST be present"));
}
#[test]
fn tampered_identity_fails_verification() {
let kp = test_keypair();
let vk = kp.verifying_key_base64();
let mut envelope =
SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
envelope.identity = "did:sigil:attacker".to_string();
assert!(
!envelope.verify(&vk).unwrap(),
"Tampered identity must fail verification"
);
}
#[test]
fn tampered_verdict_fails_verification() {
let kp = test_keypair();
let vk = kp.verifying_key_base64();
let mut envelope =
SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
envelope.verdict = Verdict::Scanned;
assert!(
!envelope.verify(&vk).unwrap(),
"Tampered verdict must fail verification"
);
}
#[test]
fn wrong_keypair_fails_verification() {
let kp1 = SigilKeypair::generate();
let kp2 = SigilKeypair::generate();
let envelope =
SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp1).unwrap();
let wrong_vk = kp2.verifying_key_base64();
assert!(
!envelope.verify(&wrong_vk).unwrap(),
"Wrong keypair must fail verification"
);
}
#[test]
fn nonce_is_16_bytes_hex() {
let kp = test_keypair();
let envelope =
SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
assert_eq!(envelope.nonce.len(), 32);
assert!(
envelope.nonce.chars().all(|c| c.is_ascii_hexdigit()),
"Nonce must be hex-encoded"
);
}
#[test]
fn nonces_are_unique() {
let kp = test_keypair();
let e1 =
SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
let e2 =
SigilEnvelope::sign("did:sigil:parent_01", Verdict::Allowed, None, &kp).unwrap();
assert_ne!(e1.nonce, e2.nonce, "Each envelope must have a unique nonce");
}
#[test]
fn envelope_roundtrips_json() {
let kp = test_keypair();
let vk = kp.verifying_key_base64();
let original =
SigilEnvelope::sign("did:sigil:parent_01", Verdict::Scanned, Some("PII detected".into()), &kp)
.unwrap();
let json = serde_json::to_string(&original).unwrap();
let deserialized: SigilEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.identity, original.identity);
assert_eq!(deserialized.verdict, original.verdict);
assert_eq!(deserialized.signature, original.signature);
assert!(deserialized.verify(&vk).unwrap(), "Deserialized envelope must verify");
}
#[test]
fn keypair_from_seed_is_deterministic() {
let seed = [99u8; 32];
let kp1 = SigilKeypair::from_seed(&seed);
let kp2 = SigilKeypair::from_seed(&seed);
assert_eq!(kp1.verifying_key_base64(), kp2.verifying_key_base64());
}
#[cfg(feature = "registry")]
#[tokio::test]
#[ignore = "requires network access to registry.sigil-protocol.org"]
async fn live_registry_health_check() {
let resp = reqwest::get("https://registry.sigil-protocol.org/health")
.await
.expect("Registry should be reachable");
assert!(resp.status().is_success(), "Health check should return 200");
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
assert_eq!(body["service"], "sigil-registry");
println!("Registry version: {}", body["version"]);
}
#[cfg(feature = "registry")]
#[tokio::test]
#[ignore = "requires network access and writes to registry.sigil-protocol.org"]
async fn live_sign_register_and_verify_with_registry() {
use crate::sigil_envelope::{SigilEnvelope, SigilKeypair, Verdict};
let test_id = format!(
"did:sigil:test_{}",
&hex::encode({
use rand_core::RngCore;
let mut b = [0u8; 4];
rand_core::OsRng.fill_bytes(&mut b);
b
})
);
let kp = SigilKeypair::generate();
let public_key = kp.verifying_key_base64();
let client = reqwest::Client::new();
let reg_resp = client
.post("https://registry.sigil-protocol.org/register")
.json(&serde_json::json!({
"did": test_id,
"public_key": public_key,
"namespace": "test",
"label": "Live integration test — auto-generated"
}))
.send()
.await
.expect("Register request should succeed");
assert_eq!(
reg_resp.status(),
reqwest::StatusCode::CREATED,
"DID registration should return 201"
);
println!("Registered: {test_id}");
let envelope = SigilEnvelope::sign(&test_id, Verdict::Allowed, None, &kp)
.expect("Signing should succeed");
let result = envelope
.verify_with_registry("https://registry.sigil-protocol.org")
.await
.expect("verify_with_registry should not error");
assert!(
result.valid,
"Live round-trip verification failed: {:?}",
result.reason
);
assert_eq!(result.status, "active");
println!("✅ Live end-to-end verification passed for {test_id}");
}
}