clusterflux-public/crates/clusterflux-node/src/node_identity.rs
Clusterflux release a2664f4345 Public release release-f6c01dca61ee
Source commit: f6c01dca61eefca1a7431c48878a04dbb7a3f710

Public tree identity: sha256:3df7a93eb61c8c5c7841ac9ad6eedec89b167fd77fdd32e9fc31b57c10207387
2026-07-17 03:04:27 +02:00

224 lines
7.1 KiB
Rust

use std::path::{Path, PathBuf};
use clusterflux_core::{
generate_ed25519_private_key, node_ed25519_public_key_from_private_key, sign_node_request,
signed_request_payload_digest, NodeId,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::coordinator_session::CoordinatorSession;
use crate::daemon::Args;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct StoredNodeCredential {
kind: String,
node: String,
private_key: String,
public_key: String,
credential_scope: String,
}
pub(crate) fn establish_node_identity(
session: &mut CoordinatorSession,
args: &Args,
node_private_key: &str,
) -> Result<Value, Box<dyn std::error::Error>> {
let derived_public_key =
node_ed25519_public_key_from_private_key(node_private_key).map_err(invalid_key_error)?;
let public_key = args
.public_key
.clone()
.unwrap_or(derived_public_key.clone());
if public_key != derived_public_key {
return Err(
"node --public-key must match CLUSTERFLUX_NODE_PRIVATE_KEY or the stored local node credential"
.into(),
);
}
if let Some(grant) = &args.enrollment_grant {
session.request(json!({
"type": "exchange_node_enrollment_grant",
"tenant": &args.tenant,
"project": &args.project,
"node": &args.node,
"public_key": public_key,
"enrollment_grant": grant,
}))
} else {
Ok(json!({
"type": "node_identity_reused",
"node": &args.node,
"credential_source": "stored_project_node_identity",
"subsequent_authentication": "signed_node_requests",
}))
}
}
pub(crate) fn node_private_key_for_runtime(
node: &str,
) -> Result<String, Box<dyn std::error::Error>> {
if let Ok(private_key) = std::env::var("CLUSTERFLUX_NODE_PRIVATE_KEY") {
return Ok(private_key);
}
load_or_create_local_node_credential(&std::env::current_dir()?, node)
}
pub(crate) fn load_or_create_local_node_credential(
project: &Path,
node: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let file = local_node_credential_file(project, node);
if credential_file_exists_without_symlink(&file)? {
let bytes = std::fs::read(&file)?;
let credential: StoredNodeCredential = serde_json::from_slice(&bytes)?;
if credential.node != node {
return Err(format!(
"stored node credential {} belongs to node `{}` instead of `{}`",
file.display(),
credential.node,
node
)
.into());
}
let public_key = node_ed25519_public_key_from_private_key(&credential.private_key)
.map_err(invalid_key_error)?;
if public_key != credential.public_key {
return Err(format!(
"stored node credential {} has a public key that does not match its private key",
file.display()
)
.into());
}
return Ok(credential.private_key);
}
let private_key = generate_ed25519_private_key().map_err(invalid_key_error)?;
let public_key =
node_ed25519_public_key_from_private_key(&private_key).map_err(invalid_key_error)?;
let credential = StoredNodeCredential {
kind: "clusterflux_node_credential".to_owned(),
node: node.to_owned(),
private_key: private_key.clone(),
public_key,
credential_scope: "local_project_node_identity".to_owned(),
};
persist_node_credential(&file, &credential)?;
Ok(private_key)
}
fn credential_file_exists_without_symlink(file: &Path) -> Result<bool, Box<dyn std::error::Error>> {
match std::fs::symlink_metadata(file) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(format!(
"refusing to read node credential through symbolic link {}",
file.display()
)
.into()),
Ok(metadata) if !metadata.is_file() => Err(format!(
"node credential path {} is not a regular file",
file.display()
)
.into()),
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn persist_node_credential(
file: &Path,
credential: &StoredNodeCredential,
) -> Result<(), Box<dyn std::error::Error>> {
use std::io::Write;
let parent = file
.parent()
.ok_or_else(|| format!("node credential path {} has no parent", file.display()))?;
std::fs::create_dir_all(parent)?;
if std::fs::symlink_metadata(parent)?.file_type().is_symlink() {
return Err(format!(
"refusing to store node credential through symbolic-link directory {}",
parent.display()
)
.into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
}
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
temporary
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
temporary.write_all(&serde_json::to_vec_pretty(credential)?)?;
temporary.as_file().sync_all()?;
temporary.persist_noclobber(file).map_err(|error| {
format!(
"refusing to overwrite node credential {}: {}",
file.display(),
error.error
)
})?;
Ok(())
}
pub(crate) fn local_node_credential_file(project: &Path, node: &str) -> PathBuf {
let digest = clusterflux_core::Digest::sha256(node);
let file_stem = digest.as_str().trim_start_matches("sha256:");
project
.join(".clusterflux")
.join("nodes")
.join(format!("{file_stem}.json"))
}
pub(crate) fn node_nonce(prefix: &str) -> String {
format!("{prefix}-{}-{}", unix_timestamp_nanos(), std::process::id())
}
pub(crate) fn unix_timestamp_seconds() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
pub(crate) fn signed_node_request_json(
args: &Args,
node_private_key: &str,
request_kind: &str,
request: Value,
) -> Result<Value, Box<dyn std::error::Error>> {
let payload_digest = signed_request_payload_digest(&request);
let node_signature = sign_node_request(
node_private_key,
&NodeId::from(args.node.as_str()),
request_kind,
&payload_digest,
node_nonce(request_kind),
unix_timestamp_seconds(),
)
.map_err(invalid_key_error)?;
Ok(json!({
"type": "signed_node",
"node": &args.node,
"node_signature": node_signature,
"request": request,
}))
}
fn invalid_key_error(error: String) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidInput, error)
}
pub(crate) fn unix_timestamp_nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
}