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};
const MAX_SESSION_LIFETIME_HOURS: i64 = 24;
const PROOF_TYPE: &str = "AegisDelegationProof2025";
#[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>,
}
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> {
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(),
});
}
let session_signing_key = SigningKey::generate(&mut OsRng);
let session_verifying_key = session_signing_key.verifying_key();
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;
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 {
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 {
let encoded = &sk.session_key[1..]; 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());
}
}
}
}