Public dry run dryrun-20b59dc46b72

Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19

Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9
This commit is contained in:
Disasmer release dry run 2026-07-14 10:08:15 +02:00
commit 2bef715211
221 changed files with 79792 additions and 0 deletions

View file

@ -0,0 +1,720 @@
#[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<NodeCredential, EnrollmentError> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<AgentSignedRequest, String> {
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<NodeSignedRequest, String> {
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<Vec<u8>, String> {
let encoded = value
.strip_prefix("ed25519:")
.ok_or_else(|| format!("{kind} must use ed25519:<base64> 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<u8> {
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<u8> {
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<ProcessId>,
pub task: Option<TaskInstanceId>,
pub node: Option<NodeId>,
pub artifact: Option<ArtifactId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Authorization {
pub allowed: bool,
pub reason: String,
}
impl Authorization {
pub fn allow(reason: impl Into<String>) -> Self {
Self {
allowed: true,
reason: reason.into(),
}
}
pub fn deny(reason: impl Into<String>) -> 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
);
}
}