#[cfg(not(target_arch = "wasm32"))] use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::{engine::general_purpose::STANDARD, Engine as _}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, UserId, }; pub fn admin_request_proof( admin_token: &str, operation: &str, tenant: &str, actor_user: &str, target_tenant: &str, nonce: &str, issued_at_epoch_seconds: u64, ) -> Digest { admin_request_proof_from_token_digest( &Digest::sha256(admin_token), operation, tenant, actor_user, target_tenant, nonce, issued_at_epoch_seconds, ) } pub fn admin_request_proof_from_token_digest( admin_token_digest: &Digest, operation: &str, tenant: &str, actor_user: &str, target_tenant: &str, nonce: &str, issued_at_epoch_seconds: u64, ) -> Digest { let issued_at_epoch_seconds = issued_at_epoch_seconds.to_string(); Digest::from_parts([ b"disasmer-admin-request-proof:v1".as_slice(), admin_token_digest.as_str().as_bytes(), operation.as_bytes(), tenant.as_bytes(), actor_user.as_bytes(), target_tenant.as_bytes(), nonce.as_bytes(), issued_at_epoch_seconds.as_bytes(), ]) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Actor { User(UserId), Agent(AgentId), Node(NodeId), Task(TaskInstanceId), } impl Actor { pub fn kind(&self) -> IdentityKind { match self { Self::User(_) => IdentityKind::User, Self::Agent(_) => IdentityKind::Agent, Self::Node(_) => IdentityKind::Node, Self::Task(_) => IdentityKind::Task, } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum IdentityKind { User, Agent, Node, Project, Task, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum CredentialKind { BrowserSession, CliDeviceSession, PublicKey, NodeCredential, TaskCredential, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthContext { pub tenant: TenantId, pub project: ProjectId, pub actor: Actor, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Action { CreateProject, AttachNode, CreateNodeEnrollmentGrant, ExchangeNodeEnrollmentGrant, LoginBrowser, LoginCli, EnrollAgent, List, Inspect, Mutate, ClaimTask, DebugAttach, DebugRead, DownloadArtifact, PublishArtifact, RunNativeCommand, RunContainer, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BrowserLoginFlow { pub authorization_url: String, pub callback_path: String, pub state: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PublicKeyIdentity { pub subject: Actor, pub public_key: String, pub fingerprint: Digest, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentSignedRequest { pub nonce: String, pub issued_at_epoch_seconds: u64, pub signature: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeSignedRequest { pub nonce: String, pub issued_at_epoch_seconds: u64, pub signature: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EnrollmentGrant { pub tenant: TenantId, pub project: ProjectId, pub grant_id: String, pub scope: String, pub expires_at_epoch_seconds: u64, pub consumed: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeCredential { pub node: NodeId, pub tenant: TenantId, pub project: ProjectId, pub public_key_fingerprint: Digest, pub scope: String, pub capability_policy_digest: Digest, pub credential_kind: CredentialKind, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum EnrollmentError { Expired, AlreadyConsumed, WrongScope, } impl EnrollmentGrant { pub fn exchange_for_node_identity( &mut self, node: NodeId, public_key: &str, requested_scope: &str, now_epoch_seconds: u64, ) -> Result { if self.consumed { return Err(EnrollmentError::AlreadyConsumed); } if now_epoch_seconds > self.expires_at_epoch_seconds { return Err(EnrollmentError::Expired); } if requested_scope != self.scope { return Err(EnrollmentError::WrongScope); } self.consumed = true; let capability_policy_digest = node_capability_policy_digest(&self.tenant, &self.project, &self.scope); Ok(NodeCredential { node, tenant: self.tenant.clone(), project: self.project.clone(), public_key_fingerprint: Digest::sha256(public_key), scope: self.scope.clone(), capability_policy_digest, credential_kind: CredentialKind::NodeCredential, }) } } pub fn node_capability_policy_digest( tenant: &TenantId, project: &ProjectId, scope: &str, ) -> Digest { Digest::from_parts([ b"node-capability-policy:v1".as_slice(), tenant.as_str().as_bytes(), project.as_str().as_bytes(), scope.as_bytes(), ]) } pub fn agent_ed25519_public_key_from_private_key(private_key: &str) -> Result { let private_key = decode_ed25519_key(private_key, 32, "agent private key")?; let private_key: [u8; 32] = private_key .try_into() .map_err(|_| "agent private key must be 32 bytes".to_owned())?; let signing_key = SigningKey::from_bytes(&private_key); Ok(format!( "ed25519:{}", STANDARD.encode(signing_key.verifying_key().to_bytes()) )) } pub fn node_ed25519_public_key_from_private_key(private_key: &str) -> Result { agent_ed25519_public_key_from_private_key(private_key) } pub fn derive_ed25519_private_key_from_seed(seed: &str) -> String { let digest = Digest::sha256(seed); let hex = digest.as_str().trim_start_matches("sha256:"); let bytes = hex::decode(hex).expect("sha256 digest hex should decode"); format!("ed25519:{}", STANDARD.encode(bytes)) } /// Generates a new Ed25519 private key from the operating system CSPRNG. /// /// Seed-derived keys remain available for deterministic test fixtures, but /// must not be used for persisted user, Agent, or Node credentials. #[cfg(not(target_arch = "wasm32"))] pub fn generate_ed25519_private_key() -> Result { let mut bytes = [0_u8; 32]; getrandom::fill(&mut bytes) .map_err(|err| format!("operating system random source failed: {err}"))?; Ok(format!("ed25519:{}", STANDARD.encode(bytes))) } /// Generates an opaque, URL-safe 256-bit token suitable for one-time grants, /// session secrets, and nonces. The label is non-secret domain separation. #[cfg(not(target_arch = "wasm32"))] pub fn generate_opaque_token(label: &str) -> Result { let mut bytes = [0_u8; 32]; getrandom::fill(&mut bytes) .map_err(|err| format!("operating system random source failed: {err}"))?; Ok(format!("{label}_{}", URL_SAFE_NO_PAD.encode(bytes))) } #[derive(Clone, Copy, Debug)] pub struct AgentWorkflowScope<'a> { pub tenant: &'a TenantId, pub project: &'a ProjectId, pub agent: &'a AgentId, pub request_kind: &'a str, pub process: &'a ProcessId, pub task: Option<&'a TaskInstanceId>, } pub fn sign_agent_workflow_request( private_key: &str, scope: AgentWorkflowScope<'_>, payload_digest: &Digest, nonce: String, issued_at_epoch_seconds: u64, ) -> Result { let private_key = decode_ed25519_key(private_key, 32, "agent private key")?; let private_key: [u8; 32] = private_key .try_into() .map_err(|_| "agent private key must be 32 bytes".to_owned())?; let signing_key = SigningKey::from_bytes(&private_key); let message = agent_workflow_signature_message(scope, payload_digest, &nonce, issued_at_epoch_seconds); let signature: Signature = signing_key.sign(&message); Ok(AgentSignedRequest { nonce, issued_at_epoch_seconds, signature: format!("ed25519:{}", STANDARD.encode(signature.to_bytes())), }) } pub fn verify_agent_workflow_signature( public_key: &str, scope: AgentWorkflowScope<'_>, payload_digest: &Digest, signed_request: &AgentSignedRequest, ) -> Result<(), String> { let public_key = decode_ed25519_key(public_key, 32, "agent public key")?; let public_key: [u8; 32] = public_key .try_into() .map_err(|_| "agent public key must be 32 bytes".to_owned())?; let verifying_key = VerifyingKey::from_bytes(&public_key) .map_err(|_| "agent public key is not a valid Ed25519 verifying key".to_owned())?; let signature = decode_ed25519_key(&signed_request.signature, 64, "agent signature")?; let signature: [u8; 64] = signature .try_into() .map_err(|_| "agent signature must be 64 bytes".to_owned())?; let signature = Signature::from_bytes(&signature); let message = agent_workflow_signature_message( scope, payload_digest, &signed_request.nonce, signed_request.issued_at_epoch_seconds, ); verifying_key .verify(&message, &signature) .map_err(|_| "agent signature does not verify against the registered public key".to_owned()) } pub fn sign_node_request( private_key: &str, node: &NodeId, request_kind: &str, payload_digest: &Digest, nonce: String, issued_at_epoch_seconds: u64, ) -> Result { let private_key = decode_ed25519_key(private_key, 32, "node private key")?; let private_key: [u8; 32] = private_key .try_into() .map_err(|_| "node private key must be 32 bytes".to_owned())?; let signing_key = SigningKey::from_bytes(&private_key); let message = node_request_signature_message( node, request_kind, payload_digest, &nonce, issued_at_epoch_seconds, ); let signature: Signature = signing_key.sign(&message); Ok(NodeSignedRequest { nonce, issued_at_epoch_seconds, signature: format!("ed25519:{}", STANDARD.encode(signature.to_bytes())), }) } pub fn verify_node_request_signature( public_key: &str, node: &NodeId, request_kind: &str, payload_digest: &Digest, signed_request: &NodeSignedRequest, ) -> Result<(), String> { let public_key = decode_ed25519_key(public_key, 32, "node public key")?; let public_key: [u8; 32] = public_key .try_into() .map_err(|_| "node public key must be 32 bytes".to_owned())?; let verifying_key = VerifyingKey::from_bytes(&public_key) .map_err(|_| "node public key is not a valid Ed25519 verifying key".to_owned())?; let signature = decode_ed25519_key(&signed_request.signature, 64, "node signature")?; let signature: [u8; 64] = signature .try_into() .map_err(|_| "node signature must be 64 bytes".to_owned())?; let signature = Signature::from_bytes(&signature); let message = node_request_signature_message( node, request_kind, payload_digest, &signed_request.nonce, signed_request.issued_at_epoch_seconds, ); verifying_key .verify(&message, &signature) .map_err(|_| "node signature does not verify against the enrolled public key".to_owned()) } fn decode_ed25519_key(value: &str, expected_len: usize, kind: &str) -> Result, String> { let encoded = value .strip_prefix("ed25519:") .ok_or_else(|| format!("{kind} must use ed25519: encoding"))?; let bytes = STANDARD .decode(encoded) .map_err(|_| format!("{kind} is not valid base64"))?; if bytes.len() != expected_len { return Err(format!("{kind} must be {expected_len} bytes")); } Ok(bytes) } fn agent_workflow_signature_message( scope: AgentWorkflowScope<'_>, payload_digest: &Digest, nonce: &str, issued_at_epoch_seconds: u64, ) -> Vec { let issued_at = issued_at_epoch_seconds.to_string(); let task = scope.task.map(TaskInstanceId::as_str).unwrap_or(""); let parts = [ "disasmer-agent-workflow-signature:v2", scope.tenant.as_str(), scope.project.as_str(), scope.agent.as_str(), scope.request_kind, scope.process.as_str(), task, payload_digest.as_str(), nonce, &issued_at, ]; let mut message = Vec::new(); for part in parts { message.extend_from_slice(part.len().to_string().as_bytes()); message.push(b':'); message.extend_from_slice(part.as_bytes()); message.push(b'\n'); } message } fn node_request_signature_message( node: &NodeId, request_kind: &str, payload_digest: &Digest, nonce: &str, issued_at_epoch_seconds: u64, ) -> Vec { let issued_at = issued_at_epoch_seconds.to_string(); let parts = [ "disasmer-node-request-signature:v2", node.as_str(), request_kind, payload_digest.as_str(), nonce, &issued_at, ]; let mut message = Vec::new(); for part in parts { message.extend_from_slice(part.len().to_string().as_bytes()); message.push(b':'); message.extend_from_slice(part.as_bytes()); message.push(b'\n'); } message } /// Computes the stable digest covered by node and agent request signatures. /// /// The proof field itself is excluded, and explicit JSON nulls are normalized /// with omitted optional fields because the wire protocol deserializes both to /// the same request. Every semantically meaningful key and value remains bound /// by the signature. pub fn signed_request_payload_digest(value: &Value) -> Digest { fn canonicalize(value: &Value, top_level: bool) -> Value { match value { Value::Object(object) => Value::Object( object .iter() .filter(|(key, value)| { !value.is_null() && (!top_level || !matches!(key.as_str(), "agent_signature" | "node_signature")) }) .map(|(key, value)| (key.clone(), canonicalize(value, false))) .collect(), ), Value::Array(values) => Value::Array( values .iter() .map(|value| canonicalize(value, false)) .collect(), ), value => value.clone(), } } let canonical = canonicalize(value, true); let bytes = serde_json::to_vec(&canonical) .expect("canonical JSON request values are always serializable"); Digest::sha256(bytes) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Scope { pub tenant: TenantId, pub project: ProjectId, pub process: Option, pub task: Option, pub node: Option, pub artifact: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Authorization { pub allowed: bool, pub reason: String, } impl Authorization { pub fn allow(reason: impl Into) -> Self { Self { allowed: true, reason: reason.into(), } } pub fn deny(reason: impl Into) -> Self { Self { allowed: false, reason: reason.into(), } } } pub fn same_tenant_project(context: &AuthContext, scope: &Scope) -> Authorization { if context.tenant != scope.tenant { return Authorization::deny("tenant mismatch"); } if context.project != scope.project { return Authorization::deny("project mismatch"); } Authorization::allow("same tenant and project") } pub fn task_credentials_do_not_contain_user_session( task: &Actor, credentials: &[CredentialKind], ) -> Authorization { if !matches!(task, Actor::Task(_)) { return Authorization::deny("credential check requires task actor"); } if credentials.iter().any(|credential| { matches!( credential, CredentialKind::BrowserSession | CredentialKind::CliDeviceSession ) }) { return Authorization::deny( "user OAuth/session tokens must not be passed to nodes as task credentials", ); } Authorization::allow("task credentials are scoped runtime credentials") } #[cfg(test)] mod tests { use super::*; #[test] fn tenant_project_scope_denies_cross_tenant_access() { let context = AuthContext { tenant: TenantId::from("tenant-a"), project: ProjectId::from("project-a"), actor: Actor::User(UserId::from("user-a")), }; let scope = Scope { tenant: TenantId::from("tenant-b"), project: ProjectId::from("project-a"), process: None, task: None, node: None, artifact: None, }; assert!(!same_tenant_project(&context, &scope).allowed); } #[test] fn node_enrollment_exchanges_short_lived_grant_once() { let mut grant = EnrollmentGrant { tenant: TenantId::from("tenant"), project: ProjectId::from("project"), grant_id: "grant".to_owned(), scope: "node:attach".to_owned(), expires_at_epoch_seconds: 100, consumed: false, }; let credential = grant .exchange_for_node_identity(NodeId::from("node"), "public-key", "node:attach", 99) .unwrap(); assert_eq!(credential.credential_kind, CredentialKind::NodeCredential); assert_eq!(credential.tenant, TenantId::from("tenant")); assert_eq!(credential.project, ProjectId::from("project")); assert_eq!(credential.node, NodeId::from("node")); assert_eq!(credential.scope, "node:attach"); assert_eq!( credential.capability_policy_digest, node_capability_policy_digest( &TenantId::from("tenant"), &ProjectId::from("project"), "node:attach" ) ); assert_eq!( grant.exchange_for_node_identity( NodeId::from("node2"), "public-key", "node:attach", 99 ), Err(EnrollmentError::AlreadyConsumed) ); } #[test] fn node_capability_policy_digest_is_scoped() { let base = node_capability_policy_digest( &TenantId::from("tenant"), &ProjectId::from("project"), "node:attach", ); let other_project = node_capability_policy_digest( &TenantId::from("tenant"), &ProjectId::from("other"), "node:attach", ); let other_scope = node_capability_policy_digest( &TenantId::from("tenant"), &ProjectId::from("project"), "node:limited", ); assert!(base.is_valid_sha256()); assert_ne!(base, other_project); assert_ne!(base, other_scope); } #[test] fn generated_ed25519_private_keys_are_random_and_valid() { let first = generate_ed25519_private_key().unwrap(); let second = generate_ed25519_private_key().unwrap(); assert_ne!(first, second); assert!(agent_ed25519_public_key_from_private_key(&first) .unwrap() .starts_with("ed25519:")); assert!(node_ed25519_public_key_from_private_key(&second) .unwrap() .starts_with("ed25519:")); } #[test] fn generated_opaque_tokens_are_random_and_url_safe() { let first = generate_opaque_token("grant").unwrap(); let second = generate_opaque_token("grant").unwrap(); assert_ne!(first, second); assert!(first.starts_with("grant_")); assert!(first .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))); } #[test] fn task_credentials_reject_user_session_tokens() { for credential in [ CredentialKind::BrowserSession, CredentialKind::CliDeviceSession, ] { let authz = task_credentials_do_not_contain_user_session( &Actor::Task(TaskInstanceId::from("task")), &[CredentialKind::TaskCredential, credential], ); assert!(!authz.allowed); assert!(authz.reason.contains("must not be passed")); } let scoped = task_credentials_do_not_contain_user_session( &Actor::Task(TaskInstanceId::from("task")), &[ CredentialKind::TaskCredential, CredentialKind::NodeCredential, ], ); assert!(scoped.allowed); } #[test] fn identities_remain_distinct_for_authorization() { assert_eq!(Actor::User(UserId::from("user")).kind(), IdentityKind::User); assert_eq!( Actor::Agent(AgentId::from("agent")).kind(), IdentityKind::Agent ); assert_eq!(Actor::Node(NodeId::from("node")).kind(), IdentityKind::Node); assert_eq!( Actor::Task(TaskInstanceId::from("task")).kind(), IdentityKind::Task ); let scope = Scope { tenant: TenantId::from("tenant"), project: ProjectId::from("project"), process: Some(ProcessId::from("process")), task: Some(TaskInstanceId::from("task")), node: Some(NodeId::from("node")), artifact: Some(ArtifactId::from("artifact")), }; assert_eq!(scope.process, Some(ProcessId::from("process"))); assert_eq!(scope.artifact, Some(ArtifactId::from("artifact"))); assert_ne!( CredentialKind::BrowserSession, CredentialKind::CliDeviceSession ); assert_ne!(CredentialKind::PublicKey, CredentialKind::NodeCredential); assert_ne!( CredentialKind::NodeCredential, CredentialKind::TaskCredential ); } }