aegis-delegate 0.2.0

AEGIS delegation and authority model — delegation trees, session keys, scope constraints
Documentation
// AEGIS Delegate — Session Keys
//
// Reference: AEGIS Specification v1.0.0 §9.3
//
// Creates temporary, scoped session keys with a maximum lifetime of 24 hours.
// Session keys receive a fresh Ed25519 keypair and are signed by the
// principal's identity key.

use openagent_aegis_core::{DelegationError, DelegationProof, DelegationScope, SessionKey};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use chrono::{Duration, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};

/// Maximum session key lifetime: 24 hours.
const MAX_SESSION_LIFETIME_HOURS: i64 = 24;

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

/// Intermediate structure for canonicalizing the session key grant.
///
/// Contains all session key fields except the proof itself, so we can
/// sign before the proof exists.
#[derive(Serialize, Deserialize)]
struct SessionKeyForSigning {
    session_key: String,
    principal: String,
    scope: DelegationScope,
    #[serde(skip_serializing_if = "Option::is_none")]
    max_transactions: Option<u64>,
    created: chrono::DateTime<chrono::Utc>,
    expires: chrono::DateTime<chrono::Utc>,
}

/// Creates a session key (temporary, scoped, max 24h).
///
/// Generates a fresh Ed25519 keypair for the session. The public key is
/// encoded as multibase (base64url) and stored in the [`SessionKey`] struct.
/// The session key grant is signed by the principal's identity key.
///
/// # Errors
///
/// Returns [`DelegationError`] if:
/// - The requested lifetime exceeds 24 hours
/// - JCS canonicalization fails
///
/// # Returns
///
/// A tuple of `(SessionKey, SigningKey)` where the `SigningKey` is the
/// ephemeral private key for the session.
pub fn create_session_key(
    principal_did: &str,
    scope: DelegationScope,
    max_transactions: Option<u64>,
    lifetime: Duration,
    principal_signing_key: &SigningKey,
    verification_method: &str,
) -> Result<(SessionKey, SigningKey), DelegationError> {
    // Enforce maximum lifetime
    let max_lifetime = Duration::hours(MAX_SESSION_LIFETIME_HOURS);
    if lifetime > max_lifetime {
        return Err(DelegationError::InvalidProof {
            reason: format!(
                "session key lifetime ({} seconds) exceeds maximum ({} seconds / 24 hours)",
                lifetime.num_seconds(),
                max_lifetime.num_seconds(),
            ),
        });
    }

    if lifetime <= Duration::zero() {
        return Err(DelegationError::InvalidProof {
            reason: "session key lifetime must be positive".to_string(),
        });
    }

    // Generate ephemeral keypair
    let session_signing_key = SigningKey::generate(&mut OsRng);
    let session_verifying_key = session_signing_key.verifying_key();

    // Encode the public key as multibase (base64url, prefix 'u')
    let pubkey_b64 = URL_SAFE_NO_PAD.encode(session_verifying_key.as_bytes());
    let session_key_multibase = format!("u{pubkey_b64}");

    let now = Utc::now();
    let expires = now + lifetime;

    // Build the structure to sign
    let for_signing = SessionKeyForSigning {
        session_key: session_key_multibase.clone(),
        principal: principal_did.to_string(),
        scope: scope.clone(),
        max_transactions,
        created: now,
        expires,
    };

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

    let signature: Signature = principal_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,
    };

    let session_key = SessionKey {
        session_key: session_key_multibase,
        principal: principal_did.to_string(),
        scope,
        max_transactions,
        created: now,
        expires,
        proof,
    };

    Ok((session_key, session_signing_key))
}

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

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

    #[test]
    fn session_key_creation_succeeds() {
        let principal_key = SigningKey::generate(&mut OsRng);
        let lifetime = Duration::hours(1);

        let result = create_session_key(
            "did:oas:principal",
            test_scope(),
            Some(100),
            lifetime,
            &principal_key,
            "did:oas:principal#auth-key",
        );

        assert!(result.is_ok());
        if let Ok((sk, _signing_key)) = result {
            assert_eq!(sk.principal, "did:oas:principal");
            assert!(sk.session_key.starts_with('u'));
            assert_eq!(sk.max_transactions, Some(100));
            assert!(sk.expires > sk.created);
            assert_eq!(sk.proof.proof_type, PROOF_TYPE);
        }
    }

    #[test]
    fn session_key_rejects_over_24h() {
        let principal_key = SigningKey::generate(&mut OsRng);
        let lifetime = Duration::hours(25);

        let result = create_session_key(
            "did:oas:principal",
            test_scope(),
            None,
            lifetime,
            &principal_key,
            "did:oas:principal#key",
        );

        assert!(result.is_err());
    }

    #[test]
    fn session_key_rejects_zero_lifetime() {
        let principal_key = SigningKey::generate(&mut OsRng);
        let lifetime = Duration::zero();

        let result = create_session_key(
            "did:oas:principal",
            test_scope(),
            None,
            lifetime,
            &principal_key,
            "did:oas:principal#key",
        );

        assert!(result.is_err());
    }

    #[test]
    fn session_key_exactly_24h_succeeds() {
        let principal_key = SigningKey::generate(&mut OsRng);
        let lifetime = Duration::hours(24);

        let result = create_session_key(
            "did:oas:principal",
            test_scope(),
            None,
            lifetime,
            &principal_key,
            "did:oas:principal#key",
        );

        assert!(result.is_ok());
    }

    #[test]
    fn session_key_returns_distinct_signing_key() {
        let principal_key = SigningKey::generate(&mut OsRng);
        let lifetime = Duration::hours(1);

        let result = create_session_key(
            "did:oas:principal",
            test_scope(),
            None,
            lifetime,
            &principal_key,
            "did:oas:principal#key",
        );

        assert!(result.is_ok());
        if let Ok((_sk, session_signing)) = result {
            // The session signing key should differ from the principal key
            assert_ne!(
                session_signing.verifying_key().as_bytes(),
                principal_key.verifying_key().as_bytes(),
            );
        }
    }

    #[test]
    fn session_key_multibase_roundtrip() {
        let principal_key = SigningKey::generate(&mut OsRng);
        let lifetime = Duration::hours(1);

        let result = create_session_key(
            "did:oas:principal",
            test_scope(),
            None,
            lifetime,
            &principal_key,
            "did:oas:principal#key",
        );

        assert!(result.is_ok());
        if let Ok((sk, session_signing)) = result {
            // Decode the multibase session key and verify it matches
            let encoded = &sk.session_key[1..]; // strip 'u' prefix
            let decoded = URL_SAFE_NO_PAD.decode(encoded);
            assert!(decoded.is_ok());
            if let Ok(bytes) = decoded {
                assert_eq!(bytes.as_slice(), session_signing.verifying_key().as_bytes());
            }
        }
    }
}