aegis-delegate 0.2.0

AEGIS delegation and authority model — delegation trees, session keys, scope constraints
Documentation
// AEGIS Delegate — Delegation Proofs
//
// Reference: AEGIS Specification v1.0.0 §9.7
//
// Creates and verifies delegation proofs using Ed25519 signatures over
// JCS-canonicalized delegation objects.

use openagent_aegis_core::{Delegation, DelegationError, DelegationProof, DelegationScope};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Proof type constant per AEGIS Spec §9.7.
const PROOF_TYPE: &str = "AegisDelegationProof2025";

/// Intermediate structure used for canonicalization.
///
/// Contains every field of a [`Delegation`] except the `proof`, so we can
/// serialize, canonicalize (JCS), and sign before the proof exists.
#[derive(Serialize, Deserialize)]
struct DelegationForSigning {
    id: String,
    delegator: String,
    delegate: String,
    scope: DelegationScope,
    created: DateTime<Utc>,
    #[serde(skip_serializing_if = "Option::is_none")]
    expires: Option<DateTime<Utc>>,
    revocable: bool,
}

/// Creates a delegation proof by signing the delegation with the delegator's key.
///
/// Steps per spec §9.7:
/// 1. Construct delegation object (excluding proof field)
/// 2. Canonicalize via JCS (RFC 8785)
/// 3. Sign canonical bytes with delegator's delegation key (Ed25519)
///
/// Returns a fully formed [`Delegation`] with an attached cryptographic proof.
pub fn create_delegation_proof(
    delegator_did: &str,
    delegate_did: &str,
    scope: DelegationScope,
    expires: Option<DateTime<Utc>>,
    signing_key: &SigningKey,
    verification_method: &str,
) -> Result<Delegation, DelegationError> {
    let now = Utc::now();
    let id = Uuid::now_v7().to_string();

    let for_signing = DelegationForSigning {
        id: id.clone(),
        delegator: delegator_did.to_string(),
        delegate: delegate_did.to_string(),
        scope: scope.clone(),
        created: now,
        expires,
        revocable: true,
    };

    let canonical =
        serde_jcs::to_string(&for_signing).map_err(|e| DelegationError::InvalidProof {
            reason: format!("JCS canonicalization failed: {e}"),
        })?;

    let signature: Signature = signing_key.sign(canonical.as_bytes());
    let jws = URL_SAFE_NO_PAD.encode(signature.to_bytes());

    let proof = DelegationProof {
        proof_type: PROOF_TYPE.to_string(),
        verification_method: verification_method.to_string(),
        created: now,
        jws,
    };

    Ok(Delegation {
        id,
        delegator: delegator_did.to_string(),
        delegate: delegate_did.to_string(),
        scope,
        created: now,
        expires,
        revocable: true,
        proof,
    })
}

/// Verifies a delegation proof against the delegator's public key.
///
/// Reconstructs the canonical form of the delegation (without the proof),
/// then verifies the Ed25519 signature contained in the proof's JWS field.
pub fn verify_delegation_proof(
    delegation: &Delegation,
    delegator_public_key: &VerifyingKey,
) -> Result<bool, DelegationError> {
    let for_signing = DelegationForSigning {
        id: delegation.id.clone(),
        delegator: delegation.delegator.clone(),
        delegate: delegation.delegate.clone(),
        scope: delegation.scope.clone(),
        created: delegation.created,
        expires: delegation.expires,
        revocable: delegation.revocable,
    };

    let canonical =
        serde_jcs::to_string(&for_signing).map_err(|e| DelegationError::InvalidProof {
            reason: format!("JCS canonicalization failed: {e}"),
        })?;

    let sig_bytes = URL_SAFE_NO_PAD.decode(&delegation.proof.jws).map_err(|e| {
        DelegationError::InvalidProof {
            reason: format!("base64 decode failed: {e}"),
        }
    })?;

    let signature =
        Signature::from_slice(&sig_bytes).map_err(|e| DelegationError::InvalidProof {
            reason: format!("invalid signature bytes: {e}"),
        })?;

    match delegator_public_key.verify(canonical.as_bytes(), &signature) {
        Ok(()) => Ok(true),
        Err(_) => Ok(false),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::rngs::OsRng;

    fn test_scope() -> DelegationScope {
        DelegationScope {
            actions: vec!["transfer".to_string(), "approve".to_string()],
            resources: vec!["0xabc".to_string()],
            chains: vec!["ethereum".to_string()],
            limits: None,
            temporal: None,
        }
    }

    #[test]
    fn proof_creation_and_verification_round_trip() {
        let signing_key = SigningKey::generate(&mut OsRng);
        let verifying_key = signing_key.verifying_key();

        let result = create_delegation_proof(
            "did:oas:delegator123",
            "did:oas:delegate456",
            test_scope(),
            None,
            &signing_key,
            "did:oas:delegator123#delegation-key",
        );

        assert!(result.is_ok());
        let delegation = result.ok();

        if let Some(ref d) = delegation {
            assert_eq!(d.delegator, "did:oas:delegator123");
            assert_eq!(d.delegate, "did:oas:delegate456");
            assert_eq!(d.proof.proof_type, PROOF_TYPE);

            let verified = verify_delegation_proof(d, &verifying_key);
            assert!(verified.is_ok());
            assert_eq!(verified.ok(), Some(true));
        }
    }

    #[test]
    fn proof_fails_with_wrong_key() {
        let signing_key = SigningKey::generate(&mut OsRng);
        let wrong_key = SigningKey::generate(&mut OsRng);
        let wrong_verifying = wrong_key.verifying_key();

        let result = create_delegation_proof(
            "did:oas:delegator",
            "did:oas:delegate",
            test_scope(),
            None,
            &signing_key,
            "did:oas:delegator#key",
        );

        assert!(result.is_ok());
        if let Ok(ref d) = result {
            let verified = verify_delegation_proof(d, &wrong_verifying);
            assert!(verified.is_ok());
            assert_eq!(verified.ok(), Some(false));
        }
    }

    #[test]
    fn proof_with_expiry() {
        let signing_key = SigningKey::generate(&mut OsRng);
        let verifying_key = signing_key.verifying_key();
        let expires = Utc::now() + chrono::Duration::hours(1);

        let result = create_delegation_proof(
            "did:oas:a",
            "did:oas:b",
            test_scope(),
            Some(expires),
            &signing_key,
            "did:oas:a#key",
        );

        assert!(result.is_ok());
        if let Ok(ref d) = result {
            assert!(d.expires.is_some());
            let verified = verify_delegation_proof(d, &verifying_key);
            assert_eq!(verified.ok(), Some(true));
        }
    }

    #[test]
    fn delegation_has_uuid_v7_id() {
        let signing_key = SigningKey::generate(&mut OsRng);

        let result = create_delegation_proof(
            "did:oas:a",
            "did:oas:b",
            test_scope(),
            None,
            &signing_key,
            "did:oas:a#key",
        );

        assert!(result.is_ok());
        if let Ok(ref d) = result {
            let parsed = Uuid::parse_str(&d.id);
            assert!(parsed.is_ok());
        }
    }
}